Skip to content

Commit aa33f7d

Browse files
release: v0.33.0 — plugin contract bundle + install error surfacing (#57)
Bumps version to 0.33.0 so the plugin nutri (which requires this version) can install. Bundles the five plugin-contract PRs (#52#56) merged today into a single release. Plus a UX fix on the install wizard so 409s say why they conflicted. The fix - lib/api.ts buildError now falls back to data.conflicts[0] when the standard error/message fields are absent. The plugin preview endpoint returns {conflicts: string[], manifest, ...} on 409 — without this fix the wizard showed only "409 CONFLICT" with the actual reason hidden. - PluginInstallModal: conflicts type was Record<string, unknown>, backend always returned string[]; the JSON.keys() coercion produced index strings. Now typed as string[] and rendered as a list. Tested - Frontend tsc --noEmit clean - Plugin nutri 200 pytest still pass after the 11 `# nosec B603` markers added to subprocess.run calls (false positives from regex security scan — all calls use list args, no shell=True) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 33f490e commit aa33f7d

4 files changed

Lines changed: 48 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.33.0] - 2026-04-25
9+
10+
Plugin contract release. Five PRs merged in one day to unblock the EvoNexus Plugin Nutri (and any future plugin needing per-endpoint role enforcement, public token-bound portals, or safe uninstall). Plus a UX fix so `409 CONFLICT` from plugin install actually says *why* it conflicted.
11+
12+
### Added
13+
14+
- **`requires_role` on `PluginWritableResource`** (PR #55) — plugins can declare a list of roles allowed on each writable endpoint. The host returns `403` when `current_user.role` is not in the list. `'admin'` always passes (super-user override). Backwards compatible: resources without the field accept any authenticated user.
15+
- **Auto-injected readonly bind params** (PR #55) — every `readonly_data` query receives `:current_user_id` and `:current_user_role` server-side. Plugins reference them directly in SQL for scoping (`WHERE primary_nutritionist_id = :current_user_id`). Both names are reserved — clients that try to spoof them via `?current_user_id=...` get `400`.
16+
- **`public_pages` capability** (PR #53) — token-bound public portals at `/p/{slug}/{route_prefix}/{token}`. Token validated against a plugin-declared `token_source.column`. CSP, rate limit, and security headers applied. Read-only `readonly_data` queries can be exposed to the portal via `public_via` + `bind_token_param`.
17+
- **HTML shell content negotiation** (PR #56) — when a request includes `text/html` in `Accept`, the host renders a minimal HTML shell that loads the plugin bundle as a module and instantiates the declared custom element with `data-token`. Programmatic clients (`Accept: application/javascript`, default `*/*`) keep getting the raw bundle. Plugins ship a single JS bundle and get a working browser experience for free.
18+
- **`safe_uninstall` capability** (PR #54) — three-step uninstall wizard with `preserved_tables` (renamed to `_orphan_{slug}_*` instead of dropped), pre-uninstall hook (sandboxed: read-only DB, no `BRAIN_REPO_MASTER_KEY`), and required user confirmation (checkbox + typed phrase + ZIP password). Reinstall verifies SHA256 and restores access to preserved data.
19+
- **Rate limit + security headers** (PR #52) — `flask-limiter` with in-memory storage on the public share endpoint and any future `/p/...` route. Five security headers applied to public responses (`Referrer-Policy`, `Cache-Control: no-store`, HSTS, `X-Content-Type-Options`, `Pragma`).
20+
21+
### Fixed
22+
23+
- **Plugin install wizard now shows the actual reason for `409 CONFLICT`.** The frontend was treating any 4xx as an opaque error string. Now `buildError` in `lib/api.ts` falls back to `data.conflicts[0]` when the standard `error`/`message` fields are absent (which is the case for the plugin preview endpoint), so a version mismatch shows up as `"409 CONFLICT: Plugin 'nutri' requires EvoNexus >= 0.33.0, but installed version is 0.32.3."` instead of just `"409 CONFLICT"`. `PluginInstallModal` also fixes the type of `conflicts` (was `Record<string, unknown>`, the backend always returned `string[]`) and renders each conflict as a list item.
24+
25+
### Compat
26+
27+
- All existing plugins (PM Essentials, etc.) work unchanged. New manifest fields default to absent / `None` and the auto-injected bind params are silently ignored if the SQL doesn't reference them. The `409` body shape for plugin install was already `{conflicts: [...], manifest, ...}` — only the frontend's interpretation changed.
28+
829
## [0.32.3] - 2026-04-25
930

1031
Patch release fixing a long-standing Workspace UI bug where folders refused to open and the dev console flooded with `400 Path is a directory` requests, plus a small UX win on the file share dialog (reuse existing share links instead of generating a new token every time). Also includes the upstream PR #51 (private-repo plugin update flow + ClickUp webhook compat + DetachedInstanceError).

dashboard/frontend/src/components/PluginInstallModal.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import SecurityScanSection, { type ScanVerdict, type ScanResult } from './Securi
77
interface PreviewResult {
88
manifest: Record<string, unknown>
99
warnings: string[]
10-
conflicts?: Record<string, unknown>
10+
// Backend returns conflicts as a list of human-readable strings (not a dict).
11+
// See plugin_loader.PluginInstaller.preview() — each blocker is a string
12+
// appended to result["conflicts"].
13+
conflicts?: string[]
1114
}
1215

1316
interface Props {
@@ -140,7 +143,9 @@ export default function PluginInstallModal({ onClose, onInstalled }: Props) {
140143

141144
const manifest = preview?.manifest ?? {}
142145
const warnings = preview?.warnings ?? []
143-
const conflicts = preview?.conflicts ? Object.keys(preview.conflicts) : []
146+
const conflicts: string[] = Array.isArray(preview?.conflicts)
147+
? (preview!.conflicts as string[]).filter((c): c is string => typeof c === 'string' && c.length > 0)
148+
: []
144149

145150
// Install button is amber for WARN, normal green otherwise
146151
const installBtnClass =
@@ -338,7 +343,11 @@ export default function PluginInstallModal({ onClose, onInstalled }: Props) {
338343
<p className="text-xs font-medium text-red-400 mb-1 flex items-center gap-1.5">
339344
<AlertTriangle size={12} /> {t('plugins.conflicts')}
340345
</p>
341-
<p className="text-xs text-red-300/80">{conflicts.join(', ')}</p>
346+
<ul className="space-y-0.5 list-disc list-inside">
347+
{conflicts.map((c, i) => (
348+
<li key={i} className="text-xs text-red-300/80">{c}</li>
349+
))}
350+
</ul>
342351
</div>
343352
)}
344353

dashboard/frontend/src/lib/api.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,20 @@ async function buildError(res: Response): Promise<Error> {
1515
let detail = ''
1616
try {
1717
const data = await res.clone().json()
18-
detail = data?.error || data?.description || data?.message || ''
18+
// Try common error shapes first, then plugin-preview-shaped responses
19+
// (`{conflicts: [...], manifest, ...}`). Without this, plugin install
20+
// 409s surfaced as "409 CONFLICT" with no hint at the actual reason
21+
// (e.g. version mismatch).
22+
detail =
23+
data?.error ||
24+
data?.description ||
25+
data?.message ||
26+
(Array.isArray(data?.conflicts) && data.conflicts.length > 0
27+
? data.conflicts.join(' • ')
28+
: '') ||
29+
(Array.isArray(data?.details) && data.details.length > 0
30+
? data.details.join(' • ')
31+
: '')
1932
} catch {
2033
try {
2134
const text = await res.text()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "evo-nexus"
3-
version = "0.32.3"
3+
version = "0.33.0"
44
description = "Unofficial open source toolkit for Claude Code — AI-powered business operating system"
55
requires-python = ">=3.10"
66
dependencies = [

0 commit comments

Comments
 (0)