* feat(store): hard-reject inline guardrail failures, trace security only
Inline failures (manifest + content validation, static-security
deny-list hits) now hard-reject upstream of any DB write or bundle
persistence. The v30 contract that landed every inline failure as a
hidden+blocked_inline entity + admin-rescannable bundle is replaced
with two response shapes:
- 422 code=validation_failed — manifest/content issues. Banner-only,
no submission row, no audit_log entry. Submitter fixes and retries.
- 422 code=security_blocked — static_scan finding. Banner-only on
the wire, plus one audit_log row (store.upload.security_blocked)
carrying findings + sha256 + size for admin forensics.
Quarantine + admin rescan/override apply only to the async LLM path
(blocked_llm / review_error) — the cases that genuinely benefit from
admin judgment.
Spam-quota counter narrows to blocked_llm + review_error. Admin queue
filter chip drops blocked_inline. Bundle TTL purge stops sweeping
blocked_inline. Legacy blocked_inline rows from instances that ran
the v30 contract remain reachable via the "All" tab.
New _reject_inline_or_continue helper in app/api/store.py centralises
the two-tier rejection across create_entity, update_entity, and
restore_version. Frontend templates render the new payloads as inline
banners (no redirect on failure) and keep submission_blocked as a
one-release back-compat branch.
Tests: new _seed_quarantined_entity helper replaces the older
_make_eval_skill_zip-driven setup wherever a test needs a
hidden+blocked_llm entity. 199 store tests pass under -n auto.
* release: 0.54.8 — store inline hard-reject (BREAKING)
Last commit on the PR per CLAUDE.md hard rule. Patch bump (0.54.7 →
0.54.8) wrapping Vojta's hard-reject refactor.
**BREAKING for store-upload clients**: validation failures now return
422 with `code='validation_failed'` (no entity row, no submission row,
no audit_log entry) instead of the v30 `submission_blocked` 200
response that landed a hidden `blocked_inline` row. Frontend wizard +
edit + restore still understand the legacy code for one release as a
fallback for stale clients hitting an older deploy. Operators with
custom integrations against `POST /api/store/entities` should update
to handle the new `code='validation_failed'` / `code='security_blocked'`
422 responses.
No DB migration required (legacy `blocked_inline` rows from instances
that ran the v30 contract remain reachable via the admin queue's
"All" tab; bundle-purge job no longer covers them but they linger
harmlessly).
---------
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
345 lines
14 KiB
HTML
345 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Edit {{ entity.name | store_display_name }} — {{ config.INSTANCE_NAME }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
.edit-back { font-size: 13px; color: var(--text-secondary, #6b7280); text-decoration: none; }
|
|
.edit-back:hover { color: var(--text, #111827); text-decoration: underline; }
|
|
|
|
h1.edit-title { margin: 8px 0 16px 0; font-size: 22px; font-weight: 600; }
|
|
.field { margin-bottom: 18px; }
|
|
.field-label {
|
|
display: block; font-size: 13px; font-weight: 500;
|
|
color: var(--text, #111827); margin-bottom: 6px;
|
|
}
|
|
.field-help {
|
|
font-size: 12px; color: var(--text-secondary, #6b7280); margin-top: 4px;
|
|
}
|
|
.field-help.warn { color: #92400e; }
|
|
|
|
input[type=text], textarea, select {
|
|
width: 100%; padding: 8px 10px;
|
|
border: 1px solid var(--border, #d1d5db); border-radius: 6px;
|
|
font-size: 14px; font-family: inherit;
|
|
background: var(--surface, #fff);
|
|
}
|
|
textarea { min-height: 80px; resize: vertical; }
|
|
|
|
.file-drop {
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 16px; border-radius: 8px;
|
|
border: 2px dashed var(--border, #d1d5db); background: var(--surface-muted, #f9fafb);
|
|
cursor: pointer;
|
|
}
|
|
.file-drop input[type=file] { display: none; }
|
|
.file-drop:hover { border-color: var(--primary, #0073D1); background: #fff; }
|
|
.file-drop.is-dragover { border-color: var(--primary, #0073D1); background: #eff6ff; }
|
|
.file-drop .icon { font-size: 24px; }
|
|
.file-drop .file-info .label { font-size: 13px; font-weight: 500; }
|
|
.file-drop .file-info .meta { font-size: 12px; color: var(--text-secondary, #6b7280); }
|
|
.file-drop button {
|
|
margin-left: auto; padding: 6px 12px; border-radius: 6px;
|
|
border: 1px solid var(--border, #d1d5db); background: var(--surface, #fff);
|
|
font-size: 13px; cursor: pointer;
|
|
}
|
|
|
|
.actions { display: flex; gap: 8px; margin-top: 20px; align-items: center; }
|
|
.btn-primary {
|
|
padding: 8px 18px; border-radius: 6px; border: none;
|
|
background: var(--primary, #0073D1); color: white;
|
|
font-size: 14px; font-weight: 500; cursor: pointer;
|
|
}
|
|
.btn-primary:disabled { background: var(--border, #d1d5db); cursor: not-allowed; }
|
|
.btn-link {
|
|
padding: 8px 12px; color: var(--text-secondary, #6b7280); text-decoration: none;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.pending-banner {
|
|
margin: 12px 0 16px 0; padding: 14px 18px; border-radius: 10px;
|
|
background: #fef3c7; color: #92400e; border: 1px solid #fde68a; font-size: 14px;
|
|
}
|
|
.pending-banner h3 { margin: 0 0 6px 0; font-size: 15px; }
|
|
|
|
.banner {
|
|
padding: 12px 16px; border-radius: 8px; margin-bottom: 16px;
|
|
font-size: 13px; line-height: 1.5; display: flex; align-items: flex-start; gap: 10px;
|
|
}
|
|
/* `display: flex` above overrides the user-agent default rule for
|
|
[hidden] (display:none); force it back so the empty banner stays
|
|
out of the layout until showError() unhides it. */
|
|
.banner[hidden] { display: none !important; }
|
|
.banner.error { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; }
|
|
.banner > span { white-space: pre-wrap; }
|
|
</style>
|
|
|
|
<div class="page-shell">
|
|
<p style="margin: 8px 0;">
|
|
<a class="edit-back" href="/marketplace/flea/{{ entity.id }}">← Back to detail</a>
|
|
</p>
|
|
<h1 class="edit-title">Edit · {{ entity.type }} · {{ entity.name | store_display_name }}</h1>
|
|
|
|
{% if pending_sub %}
|
|
<div class="pending-banner">
|
|
<h3>⟳ A previous edit is still under review</h3>
|
|
<div>
|
|
Submission <code>{{ pending_sub.id[:8] }}</code> is being checked
|
|
({{ pending_sub.status }}). Edits are temporarily disabled until
|
|
the verdict lands. The detail page auto-refreshes when it does.
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div id="banner" class="banner error" hidden>
|
|
<span class="ico">!</span><span id="banner-text"></span>
|
|
</div>
|
|
|
|
<form id="edit-form">
|
|
<div class="field">
|
|
<label class="field-label" for="f-name">Display name</label>
|
|
<input id="f-name" name="name" type="text" value="{{ entity.name | store_display_name }}"
|
|
pattern="^[a-z][a-z0-9-]{0,63}$"
|
|
{% if pending_sub %}disabled{% endif %}>
|
|
<div class="field-help warn">
|
|
⚠ Changing the name renames the plugin slug for existing
|
|
installers. They'll see the plugin renamed on their next sync
|
|
and may need to re-add it to their stack.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="field-label" for="f-description">Description</label>
|
|
<textarea id="f-description" name="description" maxlength="1000"
|
|
{% if pending_sub %}disabled{% endif %}>{{ entity.description or "" }}</textarea>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="field-label" for="f-category">Category</label>
|
|
<select id="f-category" name="category"
|
|
{% if pending_sub %}disabled{% endif %}>
|
|
<option value="">— none —</option>
|
|
{% for cat in categories %}
|
|
<option value="{{ cat }}" {% if cat == entity.category %}selected{% endif %}>{{ cat }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="field-label" for="f-video">Video URL <span style="color:var(--text-secondary,#6b7280);font-weight:400;">(optional)</span></label>
|
|
<input id="f-video" name="video_url" type="text"
|
|
value="{{ entity.video_url or '' }}" placeholder="https://..."
|
|
{% if pending_sub %}disabled{% endif %}>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="field-label">Cover photo</label>
|
|
<div class="file-drop" id="photo-drop">
|
|
<div class="icon">🖼</div>
|
|
<div class="file-info">
|
|
<div class="label" id="photo-label">
|
|
{% if entity.photo_path %}Replace existing photo{% else %}No photo{% endif %}
|
|
</div>
|
|
<div class="meta">JPG, PNG, WebP · Max 5 MB</div>
|
|
</div>
|
|
<input type="file" id="photo" accept="image/jpeg,image/png,image/webp"
|
|
{% if pending_sub %}disabled{% endif %}>
|
|
<button type="button" id="photo-pick" {% if pending_sub %}disabled{% endif %}>Choose</button>
|
|
</div>
|
|
</div>
|
|
|
|
<hr style="border:none;border-top:1px solid var(--border-light, #e5e7eb); margin: 20px 0;">
|
|
|
|
<div class="field">
|
|
<label class="field-label">Upload new version <span style="color:var(--text-secondary,#6b7280);font-weight:400;">(optional)</span></label>
|
|
<div class="file-drop" id="zip-drop">
|
|
<div class="icon">📦</div>
|
|
<div class="file-info">
|
|
<div class="label" id="zip-label">No file selected</div>
|
|
<div class="meta" id="zip-meta">
|
|
Skip to update only the metadata above. Uploading creates
|
|
v{{ (entity.version_no or 1) + 1 }} and re-runs guardrails.
|
|
</div>
|
|
</div>
|
|
<input type="file" id="zip" accept=".zip"
|
|
{% if pending_sub %}disabled{% endif %}>
|
|
<button type="button" id="zip-pick" {% if pending_sub %}disabled{% endif %}>Choose file</button>
|
|
</div>
|
|
<div class="field-help">
|
|
Current version: <strong>v{{ entity.version_no or 1 }}</strong>
|
|
({{ entity.version[:12] if entity.version else "—" }}).
|
|
Prior versions remain on disk and can be restored from the
|
|
detail page's <em>Versions</em> section.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<button type="submit" class="btn-primary" id="save-btn"
|
|
{% if pending_sub %}disabled{% endif %}>Save</button>
|
|
<a href="/marketplace/flea/{{ entity.id }}" class="btn-link">Cancel</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<script>
|
|
const ENTITY_ID = {{ entity.id|tojson }};
|
|
const banner = document.getElementById('banner');
|
|
const bannerText = document.getElementById('banner-text');
|
|
function showError(msg) {
|
|
banner.className = 'banner error';
|
|
bannerText.textContent = msg;
|
|
banner.hidden = false;
|
|
banner.scrollIntoView({behavior: 'smooth', block: 'start'});
|
|
}
|
|
function clearBanner() { banner.hidden = true; }
|
|
|
|
function humanizeError(detail) {
|
|
if (!detail) return 'Save failed.';
|
|
if (typeof detail === 'object') {
|
|
const code = detail.code || '';
|
|
const checks = detail.checks || {};
|
|
|
|
function appendFindings(lines, payload) {
|
|
const findings = (payload?.findings) || [];
|
|
for (const f of findings.slice(0, 5)) {
|
|
lines.push('• ' + (f.file || '?') + ':' + (f.line || '?') + ' — ' + (f.reason || f.category || ''));
|
|
}
|
|
if (findings.length > 5) lines.push(' …and ' + (findings.length - 5) + ' more.');
|
|
}
|
|
function appendManifest(lines, payload) {
|
|
const issues = (payload?.issues) || [];
|
|
for (const m of issues.slice(0, 5)) lines.push('• manifest: ' + m);
|
|
if (issues.length > 5) lines.push(' …and ' + (issues.length - 5) + ' more.');
|
|
}
|
|
function appendContent(lines, payload) {
|
|
const issues = (payload?.issues) || [];
|
|
for (const i of issues.slice(0, 5)) {
|
|
const where = i.component_type === 'submission'
|
|
? 'Description on the form'
|
|
: ((i.component_type || 'component') + (i.name ? ' — ' + i.name : ''));
|
|
const code = (i.code || '').replace(/_/g, ' ');
|
|
lines.push('• ' + where + ' — ' + (i.field || 'description') + ' ' + code);
|
|
}
|
|
if (issues.length > 5) lines.push(' …and ' + (issues.length - 5) + ' more.');
|
|
}
|
|
|
|
if (code === 'validation_failed') {
|
|
const lines = ['New version needs fixing before it can be saved.'];
|
|
appendManifest(lines, checks.manifest);
|
|
appendContent(lines, checks.content);
|
|
lines.push('');
|
|
lines.push('Fix the issues above and try again. The previous version is still live.');
|
|
return lines.join('\n');
|
|
}
|
|
if (code === 'security_blocked') {
|
|
const lines = ['Save blocked: security review found risky patterns in the new bundle.'];
|
|
appendFindings(lines, checks.static_security);
|
|
lines.push('');
|
|
lines.push('Remove the flagged code/secrets and try again. The previous version stays live.');
|
|
return lines.join('\n');
|
|
}
|
|
if (code === 'submission_blocked') {
|
|
// Legacy server response (pre-cutover) — kept for one release.
|
|
const lines = ['New version blocked by automated checks.'];
|
|
appendFindings(lines, checks.static_security);
|
|
appendManifest(lines, checks.manifest);
|
|
appendContent(lines, checks.content);
|
|
lines.push('');
|
|
lines.push('Fix the issues and re-upload, or open the detail page to see the full report.');
|
|
return lines.join('\n');
|
|
}
|
|
if (code === 'prior_version_pending') {
|
|
return 'A previous edit is still under review. Wait for the verdict before saving.';
|
|
}
|
|
if (code === 'type_locked') {
|
|
return 'Cannot change the entity type. Upload a new entity if you need a different form factor.';
|
|
}
|
|
if (code === 'conflict_owner_name') return 'You already have a plugin with that name.';
|
|
if (code === 'conflict_global_suffix') return 'That name conflicts with another user\'s plugin slug.';
|
|
if (code === 'invalid_name_format') return 'Name must be lowercase letters / digits / hyphens, starting with a letter.';
|
|
if (code) return 'Save failed: ' + code;
|
|
try { return 'Save failed: ' + JSON.stringify(detail); } catch (_) { return 'Save failed.'; }
|
|
}
|
|
return 'Save failed: ' + String(detail);
|
|
}
|
|
|
|
// File picker wiring (no double-click bug — div, not label).
|
|
function wireDropZone(dropEl, fileInput, pickBtn, labelEl, validate) {
|
|
if (!dropEl) return;
|
|
pickBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
fileInput.click();
|
|
});
|
|
dropEl.addEventListener('click', (e) => {
|
|
if (e.target.tagName !== 'BUTTON') fileInput.click();
|
|
});
|
|
fileInput.addEventListener('change', () => {
|
|
const f = fileInput.files[0];
|
|
if (!f) return;
|
|
const err = validate ? validate(f) : null;
|
|
if (err) { showError(err); fileInput.value = ''; return; }
|
|
if (labelEl) labelEl.textContent = f.name + ' (' + Math.round(f.size / 1024) + ' KB)';
|
|
dropEl.classList.add('is-dragover');
|
|
});
|
|
}
|
|
|
|
wireDropZone(
|
|
document.getElementById('zip-drop'),
|
|
document.getElementById('zip'),
|
|
document.getElementById('zip-pick'),
|
|
document.getElementById('zip-label'),
|
|
(f) => f.size > 50 * 1024 * 1024 ? 'ZIP too large (max 50 MB).' : null,
|
|
);
|
|
wireDropZone(
|
|
document.getElementById('photo-drop'),
|
|
document.getElementById('photo'),
|
|
document.getElementById('photo-pick'),
|
|
document.getElementById('photo-label'),
|
|
(f) => f.size > 5 * 1024 * 1024 ? 'Photo too large (max 5 MB).' : null,
|
|
);
|
|
|
|
document.getElementById('edit-form').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
clearBanner();
|
|
const saveBtn = document.getElementById('save-btn');
|
|
saveBtn.disabled = true;
|
|
saveBtn.textContent = 'Saving…';
|
|
try {
|
|
const fd = new FormData();
|
|
const name = document.getElementById('f-name').value.trim();
|
|
if (name) fd.append('name', name);
|
|
fd.append('description', document.getElementById('f-description').value);
|
|
fd.append('category', document.getElementById('f-category').value);
|
|
fd.append('video_url', document.getElementById('f-video').value);
|
|
const zip = document.getElementById('zip').files[0];
|
|
if (zip) fd.append('file', zip);
|
|
const photo = document.getElementById('photo').files[0];
|
|
if (photo) fd.append('photo', photo);
|
|
|
|
const r = await fetch(`/api/store/entities/${ENTITY_ID}`, {
|
|
method: 'PUT', body: fd, credentials: 'same-origin',
|
|
});
|
|
if (r.ok) {
|
|
window.location = `/marketplace/flea/${ENTITY_ID}`;
|
|
return;
|
|
}
|
|
let msg = 'Save failed.';
|
|
try {
|
|
const j = await r.json();
|
|
const eid = j?.detail?.entity_id;
|
|
if (eid && j.detail.code === 'submission_blocked') {
|
|
// Land on detail to see the banner.
|
|
window.location = `/marketplace/flea/${eid}`;
|
|
return;
|
|
}
|
|
if (j.detail) msg = humanizeError(j.detail);
|
|
} catch (_) {}
|
|
showError(msg);
|
|
} catch (err) {
|
|
showError(String(err));
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
saveBtn.textContent = 'Save';
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|