* feat(store): flea-market upload guardrails + soft delete + JOIN-based admin queue
Adds an end-to-end guardrails pipeline for store uploads (manifest +
static-security + LLM review), persists blocked bundles for forensics,
introduces soft-delete (Archive) semantics, consolidates the legacy
/store/{id} surface into /marketplace/flea/{id}, and reworks the admin
queue so lifecycle filters read live entity visibility via LEFT JOIN
rather than a denormalized submission column.
Schema v29 → v35:
* v29 store_submissions table + store_entities.visibility_status
* v30 file_size, bundle_sha256, bundle_purged_at on submissions
* v31 reshape store_submissions (drop legacy unique on entity_id)
* v32 store_entities.archived_at/by + 'archived' visibility value
* v33 drop store_submissions.retry_count (unused)
* v34 ensure idx_store_submissions_entity exists post column-drop
* v35 broaden visibility_status enum + JOIN architecture cutover
Pipeline (src/store_guardrails/):
* Inline checks: manifest_check, static_scan, quality_check
* LLM review configurable haiku|sonnet|opus (default haiku)
* BackgroundTasks-driven async path with structured-output JSON
* Per-submitter daily quota (default 50)
* 30-day TTL purge job (POST /api/admin/run-blocked-purge)
* Bundle SHA256 + size persisted; sha256 survives purge for forensics
Visibility model:
* pending | approved | hidden | archived
* _enforce_visibility returns 404 (no leak) for non-owner non-admin
* Owner sees own non-approved entries via include_owner_id widening
* Install refused with 409 entity_not_approved when not approved
Soft-delete (DELETE /api/store/entities/{id}):
* Default = soft (visibility_status='archived'); existing installs
keep getting served the bundle so users don't lose the plugin
* ?hard=true admin-only: drops bundle + cascades user_store_installs
* Hard-delete preserves entity_id on submission as tombstone so
audit_log linkage survives for the activity timeline
Admin queue lifecycle (the JOIN refactor):
* Verdict (store_submissions.status) is immutable forensic record
* Lifecycle (store_entities.visibility_status) is live state
* /admin/store/submissions Archived chip translates to
`e.visibility_status='archived'` via LEFT JOIN — any path that
flips visibility surfaces in the queue immediately
* Detail page renders Status (verdict) and Entity lifecycle side by
side so admins see "approved at review, now archived" at a glance
URL consolidation:
* /store/{id} deleted (no redirect, stale bookmarks 404)
* /marketplace/flea/{id} is the canonical detail surface
* Three in-tree callers (upload-success, my-stack card, store
listing card) updated to point at the new URL
* Quarantine banner extracted to _quarantine_banner.html partial,
self-guarded, included from both flea detail templates
* Banner JS auto-refreshes when the verdict lands by polling
/api/marketplace/flea/{id}/detail (visibility_status +
submission_status — the latter is needed because blocked_llm
keeps the entity at visibility_status='pending')
Audit log resource format:
* runner.py emits prefixed `store_submission:{id}` (post-fix)
* Detail-page timeline query handles three patterns: prefixed
submission, helper-emitted `store_entity:{sub_id}`, and bare-id
legacy rows — all surface in the activity timeline
UX fixes:
* Owner sees Under review / Quarantined / Hidden banner with status
* Install button gray-disabled (not blue) when non-approved
* Owner cannot delete quarantined entries (403); admin can
* Admin queue: filter chips, sortable columns, paging, page-size
* Auto-refresh queue every 5s while pending rows are visible
* Store upload page file picker no longer opens twice (label →
input default action collided with explicit JS handler)
Tests: 168 passed across the guardrails suites (admin submissions,
store API, inline / LLM / purge guardrails, store repositories,
marketplace filter, schema version). New regression coverage
includes: archive surfaces via JOIN even when API path is bypassed;
deleted submission renders activity timeline (tombstone); flea
detail surfaces submission_status only for owner/admin; detail page
renders Entity lifecycle row; audit log resource format covers both
helper and runner paths.
* fix(store-guardrails): PR #233 follow-up — prompt injection, atomic PUT, BG race, schema, reaper, sort whitelist
Addresses 9 of the 23 findings from the PR #233 review (spec at
docs/superpowers/specs/2026-05-09-pr233-guardrails-fixes-spec.md).
Merge-gate items #1-#6 plus high-value mediums #7, #9-#12, #23.
Architectural items (#8 enum split, #14 factory) and pure
maintainability (#15-#22) deferred to follow-ups.
Security:
* #1 prompt injection — SYSTEM_PROMPT now passed via the SDK's
dedicated system= parameter; bundle wrapped in <bundle>...</bundle>
sentinels declared data-only by the system prompt; literal
sentinel strings in user content are escaped so an adversarial
README can't forge a close tag.
* #6 static scan honesty — module docstring + admin copy + docs
declare static scan as signal not gate; .md/.txt/.rst/.html/.json/
.yaml/.yml/.toml skipped to avoid false positives on prose.
AST mode for Python deferred (separate flag, FP comparison work).
Correctness:
* #2 PUT atomicity — bundles bake into plugin.staging-<rand>/
alongside live, atomic-rename on success; failed checks leave
live tree byte-for-byte intact.
* #3 BG-task race — set_visibility_if_pending guards verdict flips
to the (pending, hidden) review window; admin archives during
review survive; skipped flips audit-logged.
* #4 v35 NOT NULL/DEFAULT — schema v35→v36 re-applies them on
store_entities.visibility_status. CHECK constraint enforced
application-side (DuckDB ADD CHECK on existing column unsupported).
* #7 stuck-review reaper — reap_stuck_llm_reviews flips pending_llm
rows older than guardrails.stuck_review_grace_seconds (default
1800) to review_error. Scheduler runs every 15 min via new
/api/admin/run-reap-stuck-reviews. Set knob to 0 to disable.
* #9 quota counter — count_blocked_for_submitter_since now counts
blocked_inline + blocked_llm + review_error so a submitter
triggering only LLM-blocked verdicts is bounded.
* #10 missing risk_level — surfaces as review_error with
error='missing_risk_level' instead of silently defaulting to
'medium' (which looked like a model-decided block).
* #11 archived_at clear — set_visibility nulls archived_at +
archived_by when transitioning out of 'archived' so a future
read doesn't show stale archive forensics on an approved row.
Maintainability:
* #12 FSM doc comment — accurate insert/transition/lifecycle
description in src/db.py near store_submissions schema.
* #23 sort-key whitelist — admin queue rejects unknown sort keys
with 400 invalid_sort_key; substring-replace footgun removed.
Deferred (separate PRs):
* #5 quota race — proper fix requires asyncio.Lock spanning the
full pipeline; threading.Lock blocks event loop, DuckDB MVCC
doesn't help. API-level slowapi bounds worst case for now.
* #6 part 3 (AST static scan), #8 (enum split), #13 (import
bundle docs), #14 (factory consolidation), #15-#22 (maint).
Tests:
* New: tests/test_store_guardrails_prompt_injection.py (corpus +
trust-boundary invariants), tests/test_store_put_atomic.py,
tests/test_store_guardrails_reaper.py.
* Extended: test_store_guardrails_llm.py (system param, missing
risk_level, BG race), test_admin_store_submissions.py (quota
counter widening, sort whitelist 400), test_store_repositories.py
(un-archive metadata clear), test_db_schema_version.py (v36).
* Full suite: 3738 passed; 17 pre-existing baseline failures
unchanged (db migration tests, cli binary rename, catalog export,
user mgmt v5 backfill — confirmed by stash + rerun on clean tree).
548 lines
24 KiB
HTML
548 lines
24 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Submission {{ sub.id[:8] }} — {{ config.INSTANCE_NAME }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
/* Width + padding come from .page-shell (style-custom.css) — same
|
|
1280px container as /dashboard, /marketplace, /admin/* peers. */
|
|
.det-back { font-size: 13px; color: var(--text-secondary, #6b7280); text-decoration: none; }
|
|
.det-back:hover { color: var(--text, #111827); text-decoration: underline; }
|
|
|
|
.det-header {
|
|
margin-top: 8px; margin-bottom: 20px;
|
|
padding: 16px 20px;
|
|
background: var(--surface, #fff);
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 12px;
|
|
}
|
|
.det-header h1 { margin: 0 0 6px 0; font-size: 20px; font-weight: 600; }
|
|
.det-header .id-line {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
|
color: var(--text-secondary, #6b7280);
|
|
}
|
|
.det-meta { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 24px; margin-top: 12px; font-size: 13px; }
|
|
.det-meta dt { color: var(--text-secondary, #6b7280); font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; }
|
|
.det-meta dd { margin: 0 0 8px 0; }
|
|
.det-meta dd a { color: var(--text, #111827); text-decoration: none; }
|
|
.det-meta dd a:hover { text-decoration: underline; }
|
|
|
|
.det-card {
|
|
margin-bottom: 16px; padding: 16px 20px;
|
|
background: var(--surface, #fff);
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 12px;
|
|
}
|
|
.det-card h2 { margin: 0 0 12px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--text-secondary, #6b7280); }
|
|
|
|
.badge {
|
|
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
|
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
|
|
}
|
|
.badge.pass, .badge.safe, .badge.low, .badge.approved { background: #d1fae5; color: #065f46; }
|
|
.badge.warn, .badge.medium { background: #fef3c7; color: #92400e; }
|
|
.badge.fail, .badge.high, .badge.critical, .badge.blocked_inline, .badge.blocked_llm, .badge.review_error { background: #fee2e2; color: #991b1b; }
|
|
.badge.pending_inline, .badge.pending_llm { background: #fef3c7; color: #92400e; }
|
|
.badge.overridden { background: #dbeafe; color: #1e40af; }
|
|
.badge.archived { background: #e5e7eb; color: #374151; }
|
|
.badge.deleted { background: #4b5563; color: #f3f4f6; }
|
|
|
|
table.sub-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
table.sub-table th, table.sub-table td {
|
|
text-align: left; padding: 6px 10px;
|
|
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
|
vertical-align: top;
|
|
}
|
|
table.sub-table th { color: var(--text-secondary, #6b7280); text-transform: uppercase; letter-spacing: 0.4px; font-size: 10px; }
|
|
table.sub-table code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
|
|
ul.issues { margin: 0; padding-left: 18px; font-size: 13px; color: var(--text-secondary, #6b7280); }
|
|
|
|
.det-summary { font-size: 14px; line-height: 1.5; margin: 8px 0 12px 0; }
|
|
.det-error-box {
|
|
padding: 10px 12px; border-radius: 6px;
|
|
background: #fee2e2; color: #991b1b; font-size: 13px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
word-break: break-word;
|
|
}
|
|
.det-recommendation {
|
|
padding: 10px 12px; border-radius: 6px;
|
|
background: #dbeafe; color: #1e40af; font-size: 13px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.override-block {
|
|
padding: 10px 12px; border-radius: 6px;
|
|
background: #dbeafe; color: #1e40af;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.det-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
|
.btn {
|
|
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border, #e5e7eb);
|
|
background: var(--surface, #fff); cursor: pointer;
|
|
font-size: 13px; font-weight: 500; color: var(--text, #111827);
|
|
}
|
|
.btn.primary { background: var(--text, #111827); color: var(--surface, #fff); border-color: var(--text, #111827); }
|
|
.btn.danger { color: #b91c1c; }
|
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
.empty-msg { font-size: 13px; color: var(--text-secondary, #6b7280); font-style: italic; }
|
|
|
|
.timeline { list-style: none; padding: 0; margin: 0; font-size: 13px; }
|
|
.timeline li {
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
|
display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: baseline;
|
|
}
|
|
.timeline li:last-child { border-bottom: none; }
|
|
.timeline .ts {
|
|
font-variant-numeric: tabular-nums; color: var(--text-secondary, #6b7280);
|
|
font-size: 12px; white-space: nowrap;
|
|
}
|
|
.timeline .action {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
|
}
|
|
.timeline .actor { color: var(--text-secondary, #6b7280); font-size: 12px; }
|
|
.timeline .relative { font-size: 11px; color: var(--text-secondary, #9ca3af); }
|
|
|
|
/* Pending banner — surfaces above the header when status is pending_*.
|
|
Auto-polls and reloads when status flips. */
|
|
.pending-banner {
|
|
margin-top: 8px; margin-bottom: 12px;
|
|
padding: 12px 16px;
|
|
background: #fef3c7; color: #92400e;
|
|
border: 1px solid #fde68a; border-radius: 8px;
|
|
font-size: 13px; display: flex; align-items: center; gap: 10px;
|
|
}
|
|
.pending-banner .spinner {
|
|
width: 14px; height: 14px;
|
|
border: 2px solid #fcd34d; border-top-color: #92400e;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.pending-banner .elapsed { color: #92400e; opacity: 0.7; font-variant-numeric: tabular-nums; margin-left: auto; }
|
|
|
|
/* Toast — short-lived "action queued" message after rescan/retry.
|
|
Fades out so admins know the request was accepted before the page
|
|
reloads on completion. */
|
|
.toast {
|
|
position: fixed; bottom: 24px; right: 24px;
|
|
background: var(--text, #111827); color: var(--surface, #fff);
|
|
padding: 10px 16px; border-radius: 8px;
|
|
font-size: 13px; box-shadow: 0 6px 20px rgba(0,0,0,0.18);
|
|
opacity: 0; transition: opacity .25s ease;
|
|
z-index: 9999;
|
|
}
|
|
.toast.show { opacity: 1; }
|
|
</style>
|
|
|
|
<div class="det-page page-shell">
|
|
<a class="det-back" href="/admin/store/submissions">← Back to all submissions</a>
|
|
|
|
{% if sub.status in ['pending_inline','pending_llm'] %}
|
|
<div class="pending-banner" id="pendingBanner">
|
|
<div class="spinner" aria-hidden="true"></div>
|
|
<div>
|
|
<strong>Review in progress</strong> — checks running against the bundle.
|
|
Page auto-refreshes when verdict lands.
|
|
</div>
|
|
<span class="elapsed" id="pendingElapsed" data-since="{{ sub.updated_at.isoformat() if sub.updated_at else '' }}">0s</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="det-header">
|
|
<h1>{{ sub.type }} · <strong>{{ sub.name | store_display_name }}</strong> {% if sub.version %}<span style="color: var(--text-secondary, #6b7280); font-weight: normal;">v{{ sub.version }}</span>{% endif %}</h1>
|
|
<div class="id-line">{{ sub.id }}</div>
|
|
<dl class="det-meta">
|
|
<div>
|
|
<dt>Status (verdict)</dt>
|
|
<dd><span class="badge {{ sub.status }}">{{ sub.status }}</span></dd>
|
|
</div>
|
|
<div>
|
|
<dt>Entity lifecycle</dt>
|
|
<dd>
|
|
{%- if entity_visibility_status -%}
|
|
<span class="badge {{ entity_visibility_status }}">{{ entity_visibility_status }}</span>
|
|
{%- if entity_visibility_status != sub.status %}
|
|
<span style="font-size: 11px; color: var(--text-secondary, #9ca3af); margin-left: 6px;">live state — verdict immutable</span>
|
|
{%- endif %}
|
|
{%- else -%}
|
|
<span class="empty-msg">entity gone</span>
|
|
{%- endif -%}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Submitter</dt>
|
|
<dd><a href="/admin/store/submissions?submitter={{ sub.submitter_id }}">{{ sub.submitter_email or sub.submitter_id }}</a></dd>
|
|
</div>
|
|
<div>
|
|
<dt>Created</dt>
|
|
<dd>{{ sub.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") if sub.created_at else "" }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Last update</dt>
|
|
<dd>
|
|
{%- if sub.updated_at -%}
|
|
{{ sub.updated_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}
|
|
<span class="relative" data-rel-since="{{ sub.updated_at.isoformat() }}"
|
|
style="display:block; font-size:11px; color: var(--text-secondary, #9ca3af);">just now</span>
|
|
{%- endif -%}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Reviewed by model</dt>
|
|
<dd>{{ sub.reviewed_by_model or "—" }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Entity ID</dt>
|
|
<dd>{% if sub.entity_id %}<code style="font-size:12px;">{{ sub.entity_id }}</code>{% else %}<span class="empty-msg">none — bundle purged or legacy row</span>{% endif %}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Bundle size</dt>
|
|
<dd>{% if sub.file_size is not none %}{% if sub.file_size < 1024 %}{{ sub.file_size }} B{% elif sub.file_size < 1048576 %}{{ "%.1f"|format(sub.file_size / 1024) }} KB{% else %}{{ "%.1f"|format(sub.file_size / 1048576) }} MB{% endif %}{% else %}—{% endif %}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Bundle SHA256</dt>
|
|
<dd>{% if sub.bundle_sha256 %}<code style="font-size:11px; word-break: break-all;">{{ sub.bundle_sha256 }}</code>{% else %}<span class="empty-msg">—</span>{% endif %}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Bundle status</dt>
|
|
<dd>
|
|
{%- if sub.bundle_purged_at -%}
|
|
<span class="badge blocked_inline">purged</span>
|
|
<span style="font-size: 12px; color: var(--text-secondary, #6b7280); margin-left: 6px;">on {{ sub.bundle_purged_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
|
|
{%- elif sub.entity_id -%}
|
|
<span class="badge approved">on disk</span>
|
|
{%- else -%}
|
|
<span class="empty-msg">no bundle (legacy row)</span>
|
|
{%- endif -%}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
{# Action buttons — same JSON endpoints as the list page. #}
|
|
<div class="det-actions">
|
|
{% if sub.status in ['blocked_inline','blocked_llm','review_error'] and sub.entity_id %}
|
|
<button class="btn primary" data-action="override" title="Force-publish this entity. Requires reason; recorded in audit_log.">Override + publish</button>
|
|
{% endif %}
|
|
{% if sub.entity_id %}
|
|
<button class="btn" data-action="rescan" title="Re-run all checks (inline + LLM) against the current bundle. Use after check rules change.">Rescan</button>
|
|
{% endif %}
|
|
{% if sub.entity_id and not sub.bundle_purged_at %}
|
|
<a class="btn" href="/api/admin/store/submissions/{{ sub.id }}/bundle.zip"
|
|
title="Download the on-disk bundle as a ZIP for forensic inspection. Audit-logged.">Download bundle</a>
|
|
{% endif %}
|
|
{% if sub.status in ['review_error','blocked_llm'] and sub.entity_id %}
|
|
<button class="btn" data-action="retry" title="Re-queue LLM review only (skips inline checks).">Retry LLM</button>
|
|
{% endif %}
|
|
<button class="btn danger" data-action="delete">Delete</button>
|
|
</div>
|
|
{# Help text: explain why override isn't shown on this row, when applicable. #}
|
|
{% if sub.status == 'approved' %}
|
|
<p style="font-size: 12px; color: var(--text-secondary, #6b7280); margin: 8px 0 0 0;">
|
|
Already approved — no override needed. Use <strong>Rescan</strong> to re-evaluate against current rules; it may flip the verdict.
|
|
</p>
|
|
{% elif sub.status == 'overridden' %}
|
|
<p style="font-size: 12px; color: var(--text-secondary, #6b7280); margin: 8px 0 0 0;">
|
|
Already admin-overridden. Rescan to clear the override and re-evaluate.
|
|
</p>
|
|
{% elif sub.status in ['blocked_inline','blocked_llm','review_error'] and not sub.entity_id %}
|
|
<p style="font-size: 12px; color: var(--text-secondary, #6b7280); margin: 8px 0 0 0;">
|
|
No override available — inline-blocked submissions are rolled back at upload time, so there's no bundle to publish. Submitter must fix and re-upload.
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# ── Override history ────────────────────────────────────────────────────── #}
|
|
{% if sub.override_by %}
|
|
<div class="det-card">
|
|
<h2>Override</h2>
|
|
<div class="override-block">
|
|
Overridden by <strong>{{ override_email or sub.override_by }}</strong>{% if sub.updated_at %} on {{ sub.updated_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}{% endif %}.
|
|
{% if sub.override_reason %}<br><em>Reason:</em> {{ sub.override_reason }}{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# ── Activity timeline ───────────────────────────────────────────────────── #}
|
|
{% if audit_rows %}
|
|
<div class="det-card">
|
|
<h2>Activity timeline</h2>
|
|
<p style="font-size: 12px; color: var(--text-secondary, #6b7280); margin: 0 0 8px 0;">
|
|
Every recorded action on this submission + linked entity, newest first.
|
|
Use this to confirm a verdict is fresh (e.g. the timestamp on the latest
|
|
<code>store.submission.rescan</code> row matches the time you clicked Rescan).
|
|
</p>
|
|
<ul class="timeline">
|
|
{% for row in audit_rows %}
|
|
<li>
|
|
<span class="ts">{{ row.timestamp.strftime("%Y-%m-%d %H:%M:%S") if row.timestamp else "" }}</span>
|
|
<span>
|
|
<span class="action">{{ row.action }}</span>
|
|
{% if row.actor_email %}<span class="actor"> · {{ row.actor_email }}</span>{% endif %}
|
|
{% if row.params and row.params is mapping %}
|
|
{% if row.params.outcome %}<span class="actor"> · → {{ row.params.outcome }}</span>{% endif %}
|
|
{% if row.params.risk_level %}<span class="actor"> · risk={{ row.params.risk_level }}</span>{% endif %}
|
|
{% if row.params.reason %}<span class="actor"> · "{{ row.params.reason[:80] }}"</span>{% endif %}
|
|
{% endif %}
|
|
</span>
|
|
<span class="relative" data-rel-since="{{ row.timestamp.isoformat() if row.timestamp else '' }}">just now</span>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# ── Inline checks ───────────────────────────────────────────────────────── #}
|
|
{% set ic = sub.inline_checks or {} %}
|
|
|
|
<div class="det-card">
|
|
<h2>Manifest check {% if ic.manifest %}<span class="badge {{ ic.manifest.status or 'pass' }}">{{ ic.manifest.status or 'pass' }}</span>{% endif %}</h2>
|
|
{% if ic.manifest and ic.manifest.issues %}
|
|
<ul class="issues">
|
|
{% for issue in ic.manifest.issues %}<li><code>{{ issue }}</code></li>{% endfor %}
|
|
</ul>
|
|
{% else %}
|
|
<div class="empty-msg">No manifest issues.</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="det-card">
|
|
<h2>Static security scan {% if ic.static_security %}<span class="badge {{ ic.static_security.status or 'pass' }}">{{ ic.static_security.status or 'pass' }}</span>{% endif %}</h2>
|
|
{% if ic.static_security and ic.static_security.findings %}
|
|
<table class="sub-table">
|
|
<thead>
|
|
<tr><th>Severity</th><th>Category</th><th>File:line</th><th>Reason</th><th>Snippet</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for f in ic.static_security.findings %}
|
|
<tr>
|
|
<td><span class="badge {{ f.severity }}">{{ f.severity }}</span></td>
|
|
<td>{{ f.category }}</td>
|
|
<td><code>{{ f.file }}:{{ f.line }}</code></td>
|
|
<td>{{ f.reason }}</td>
|
|
<td><code>{{ f.snippet }}</code></td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<div class="empty-msg">No security findings.</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="det-card">
|
|
<h2>Quality + templating {% if ic.quality %}<span class="badge {{ ic.quality.status or 'pass' }}">{{ ic.quality.status or 'pass' }}</span>{% endif %}</h2>
|
|
{% if ic.quality %}
|
|
<div style="font-size: 13px; color: var(--text-secondary, #6b7280); margin-bottom: 8px;">
|
|
Template placeholders found: <strong>{{ ic.quality.template_placeholders or 0 }}</strong>
|
|
</div>
|
|
{% if ic.quality.issues %}
|
|
<ul class="issues">
|
|
{% for issue in ic.quality.issues %}<li><code>{{ issue }}</code></li>{% endfor %}
|
|
</ul>
|
|
{% endif %}
|
|
{% if ic.quality.template_recommendation %}
|
|
<div class="det-recommendation">{{ ic.quality.template_recommendation }}</div>
|
|
{% endif %}
|
|
{% else %}
|
|
<div class="empty-msg">No quality data.</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# ── LLM review ──────────────────────────────────────────────────────────── #}
|
|
<div class="det-card">
|
|
<h2>LLM security review
|
|
{% if sub.llm_findings and sub.llm_findings.risk_level %}
|
|
<span class="badge {{ sub.llm_findings.risk_level }}">{{ sub.llm_findings.risk_level }}</span>
|
|
{% endif %}
|
|
</h2>
|
|
{% if sub.llm_findings %}
|
|
{% if sub.llm_findings.error %}
|
|
<div class="det-error-box"><strong>Error:</strong> {{ sub.llm_findings.error }}</div>
|
|
{% else %}
|
|
{% if sub.llm_findings.summary %}
|
|
<p class="det-summary">{{ sub.llm_findings.summary }}</p>
|
|
{% endif %}
|
|
{% if sub.llm_findings.findings %}
|
|
<table class="sub-table">
|
|
<thead>
|
|
<tr><th>Severity</th><th>Category</th><th>File</th><th>Explanation</th><th>Fix hint</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for f in sub.llm_findings.findings %}
|
|
<tr>
|
|
<td><span class="badge {{ f.severity }}">{{ f.severity }}</span></td>
|
|
<td>{{ f.category }}</td>
|
|
<td><code>{{ f.file }}</code></td>
|
|
<td>{{ f.explanation }}</td>
|
|
<td>{{ f.fix_hint or "" }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<div class="empty-msg">No findings — model verdict was clean.</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
{% elif sub.status in ['pending_inline', 'pending_llm'] %}
|
|
<div class="empty-msg">Review still in progress…</div>
|
|
{% else %}
|
|
<div class="empty-msg">No LLM verdict (review skipped or not yet run).</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# ── Other attempts by submitter ─────────────────────────────────────────── #}
|
|
{% if other_count %}
|
|
<div class="det-card">
|
|
<h2>Other attempts</h2>
|
|
<a href="/admin/store/submissions?submitter={{ sub.submitter_id }}">
|
|
View {{ other_count }} other submission{{ "" if other_count == 1 else "s" }} by {{ sub.submitter_email or sub.submitter_id }} →
|
|
</a>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const subId = {{ sub.id|tojson }};
|
|
const currentStatus = {{ sub.status|tojson }};
|
|
|
|
// Fire-and-forget toast in the bottom-right.
|
|
function toast(msg, ms) {
|
|
const el = document.createElement('div');
|
|
el.className = 'toast';
|
|
el.textContent = msg;
|
|
document.body.appendChild(el);
|
|
requestAnimationFrame(() => el.classList.add('show'));
|
|
setTimeout(() => {
|
|
el.classList.remove('show');
|
|
setTimeout(() => el.remove(), 300);
|
|
}, ms || 3500);
|
|
}
|
|
|
|
// Tick the "Xs / Xm Ys" elapsed counter on the pending banner.
|
|
function startElapsedTicker() {
|
|
const el = document.getElementById('pendingElapsed');
|
|
if (!el || !el.dataset.since) return;
|
|
const since = new Date(el.dataset.since);
|
|
if (isNaN(since.getTime())) return;
|
|
function tick() {
|
|
const sec = Math.max(0, Math.floor((Date.now() - since.getTime()) / 1000));
|
|
el.textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
|
|
}
|
|
tick();
|
|
setInterval(tick, 1000);
|
|
}
|
|
|
|
// Render relative timestamps ("3s ago", "12m ago", "2h ago", "3d ago")
|
|
// for every element with data-rel-since=ISO. Refreshes every 10s so
|
|
// an admin staring at the page sees the timer move and knows the
|
|
// render is live (and not a stale cached page).
|
|
function renderRelative() {
|
|
const now = Date.now();
|
|
document.querySelectorAll('[data-rel-since]').forEach((el) => {
|
|
const iso = el.dataset.relSince;
|
|
if (!iso) { el.textContent = ''; return; }
|
|
const t = new Date(iso).getTime();
|
|
if (isNaN(t)) return;
|
|
const sec = Math.max(0, Math.floor((now - t) / 1000));
|
|
let txt;
|
|
if (sec < 5) txt = 'just now';
|
|
else if (sec < 60) txt = `${sec}s ago`;
|
|
else if (sec < 3600) txt = `${Math.floor(sec / 60)}m ago`;
|
|
else if (sec < 86400) txt = `${Math.floor(sec / 3600)}h ago`;
|
|
else txt = `${Math.floor(sec / 86400)}d ago`;
|
|
el.textContent = txt;
|
|
});
|
|
}
|
|
renderRelative();
|
|
setInterval(renderRelative, 10000);
|
|
|
|
// Poll status; reload when it flips out of pending_*.
|
|
// ``initialDelayMs`` lets us wait a beat after a fresh action POST so
|
|
// we don't catch the database mid-write.
|
|
function startPolling(initialDelayMs) {
|
|
let attempts = 0;
|
|
const tick = async () => {
|
|
attempts++;
|
|
try {
|
|
const r = await fetch(`/api/admin/store/submissions/${subId}`, {
|
|
credentials: 'same-origin',
|
|
});
|
|
if (!r.ok) return;
|
|
const sub = await r.json();
|
|
if (sub.status !== 'pending_inline' && sub.status !== 'pending_llm') {
|
|
// Verdict landed — reload to render the new state.
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
} catch (e) { /* network blip; keep polling */ }
|
|
// Back off after first 30 attempts (90s) to spare the LLM if it's
|
|
// running a long Sonnet/Opus review.
|
|
const next = attempts < 30 ? 3000 : 10000;
|
|
setTimeout(tick, next);
|
|
};
|
|
setTimeout(tick, initialDelayMs || 3000);
|
|
}
|
|
|
|
document.querySelectorAll('button[data-action]').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
const action = btn.dataset.action;
|
|
btn.disabled = true;
|
|
try {
|
|
if (action === 'override') {
|
|
const reason = prompt('Override reason (required, ≥ 4 chars):');
|
|
if (!reason || reason.length < 4) { btn.disabled = false; return; }
|
|
const r = await fetch(`/api/admin/store/submissions/${subId}/override`, {
|
|
method: 'POST', credentials: 'same-origin',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({reason}),
|
|
});
|
|
if (!r.ok) { alert('Override failed: ' + await r.text()); btn.disabled = false; return; }
|
|
window.location.reload();
|
|
} else if (action === 'retry') {
|
|
const r = await fetch(`/api/admin/store/submissions/${subId}/retry`, {
|
|
method: 'POST', credentials: 'same-origin',
|
|
});
|
|
if (!r.ok) { alert('Retry failed: ' + await r.text()); btn.disabled = false; return; }
|
|
// Don't reload — show toast, let the polling pick up the
|
|
// verdict so the admin sees the in-progress state.
|
|
toast('LLM review re-queued — polling for verdict…');
|
|
setTimeout(() => window.location.reload(), 1500);
|
|
} else if (action === 'rescan') {
|
|
if (!confirm('Re-run all checks against the current bundle? Verdict may flip.')) {
|
|
btn.disabled = false; return;
|
|
}
|
|
const r = await fetch(`/api/admin/store/submissions/${subId}/rescan`, {
|
|
method: 'POST', credentials: 'same-origin',
|
|
});
|
|
if (!r.ok) { alert('Rescan failed: ' + await r.text()); btn.disabled = false; return; }
|
|
toast('Rescan queued — polling for verdict…');
|
|
// Reload after a beat so the page re-renders with the
|
|
// pending banner; that page will start its own poll loop.
|
|
setTimeout(() => window.location.reload(), 1500);
|
|
} else if (action === 'delete') {
|
|
if (!confirm('Delete submission and its bundle? This cannot be undone.')) {
|
|
btn.disabled = false; return;
|
|
}
|
|
const r = await fetch(`/api/admin/store/submissions/${subId}`, {
|
|
method: 'DELETE', credentials: 'same-origin',
|
|
});
|
|
if (!r.ok) { alert('Delete failed: ' + await r.text()); btn.disabled = false; return; }
|
|
window.location.href = '/admin/store/submissions';
|
|
}
|
|
} catch (e) {
|
|
alert('Action failed: ' + e);
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
if (currentStatus === 'pending_inline' || currentStatus === 'pending_llm') {
|
|
startElapsedTicker();
|
|
startPolling(3000);
|
|
}
|
|
})();
|
|
</script>
|
|
{% endblock %}
|