Repository navigation
feat(plugins): ✨ daily update checks and security advisories from public JSON manifests - #881
Conversation
…lic JSON manifests Stage 2 of plugin management. Core and every plugin that declares `vitnode.updates.url` in its package.json are checked once a day (and on demand) against public update manifests, never npm. Results appear in AdminCP → System → Plugins; nothing is installed or changed. - Versioned Zod contract for update manifests (stable/rc/canary channels, releases, advisories), shared by the server and the release tooling - `updates` carried through manifest.gen.ts and the frontend inventory - Server-only checker: one fetch per distinct source, HTTPS-only with SSRF guards on every hop and at connect time, timeout, streamed size limit, bounded concurrency, ETag/Last-Modified with 304 reuse - SemVer evaluation of updates, advisories (prereleases included), core compatibility and an older compatible target; API and web app versions evaluated separately - Persistence in core_update_sources / core_update_results with failure isolation, preserved findings and stale detection after redeploys - Atomic database lease shared by the daily cron job and the manual `POST /admin/plugins/updates/check` (plugins.can_check_updates), with expiry, owner-only release and a manual cooldown - AdminCP core card, update/compatibility/failure states, red security warnings, frontend inventory notice and a Check for updates button - Release workflow publishes updates.json to the `update-manifest` branch after npm publication; advisories come from security/advisories.json - Documentation under Development → Updates & Security Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01412WzAxDRk5MbUe4C2S2N1
…eed merging Regression tests for the update checker: manifest contract, SemVer and channel evaluation, prerelease advisory matching, API/frontend mismatch, compatibility, safe fetching, shared sources, failure preservation, 304 reuse, stale results, the database lease and cooldown, route permissions, release-manifest merging and the catalog's update states. Generator tests now expect the `updates` field. Also only accept a 304 when a conditional header was actually sent, and fix two swapped comments on UpdateStatus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01412WzAxDRk5MbUe4C2S2N1
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
React Doctor found 5 new issues in 4 files · 5 warnings · score 89 / 100 (Great) · 0 fixed · vs 5 warnings
Reviewed by React Doctor for commit |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7edc42f58e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| await assertFetchableUrl(url, resolveHost); | ||
| if (signal.aborted) throw new UpdateSourceError("timeout"); |
There was a problem hiding this comment.
Apply the timeout while resolving update hosts
When DNS resolution is delayed or the system resolver stops responding, timeoutMs does not bound the update fetch because assertFetchableUrl awaits resolveHost before the abort signal is consulted or passed to the transport. A single affected source can therefore leave the manual endpoint and the entire scheduled check pending indefinitely rather than failing after the documented 10 seconds; race the resolution step against the timeout or otherwise make it abortable.
Useful? React with 👍 / 👎.
| /** Longer than a check can take: every source has a 10 s timeout and they run four at a time. */ | ||
| export const UPDATE_CHECK_LEASE_MS = 5 * 60_000; |
There was a problem hiding this comment.
Keep the lease valid for the full source queue
For installations with more than 120 distinct update sources that each approach the 10-second timeout, four-way concurrency makes a check exceed this fixed five-minute lease. Another API instance or manual request can then acquire the expired lease while the first check is still writing results and pruning rows, violating the one-check-at-a-time guarantee and causing duplicate traffic and interleaved database state; renew the lease during the run or size it from the queued work.
Useful? React with 👍 / 👎.
- Introduced `DebugNav` component for navigation within the debug section. - Created `DebugOverview` component to summarize system health and issues. - Added various sections including `SecurityUpdateAlert`, `VitnodeSection`, `PluginsSection`, and `ServicesSection` to provide detailed insights into system status. - Implemented reusable components like `FrameRow`, `CheckRow`, and `FactRow` for consistent UI structure. - Established tone indicators for health checks with `ToneIcon`, `ToneDot`, and `ToneBadge`. - Initialized `security-updates.json` for future security update management.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4821f22e5e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| return { | ||
| count: result.securityUpdates.length, | ||
| fixedVersion: result.latest?.version ?? securityUpdate.fixed, |
There was a problem hiding this comment.
Preserve the advisory's declared fixed version
When a core advisory has fixed: null but its package still has a normal channel target, result.latest is non-null and this expression presents that target as the fix. In the common no-fix case where the target is the currently installed, still-affected release, the Debug Panel says to update to the same vulnerable version instead of showing “No fixed version has been published”; use securityUpdate.fixed, or only substitute latest after proving it is outside the advisory's affected range.
Useful? React with 👍 / 👎.
| validators = { | ||
| etag: response.etag, | ||
| lastModified: response.lastModified, | ||
| }; |
There was a problem hiding this comment.
Bound validators before writing them
A source can return an ETag longer than 512 characters or a Last-Modified value longer than 128 characters, but these values are copied into correspondingly bounded varchar columns without validation. PostgreSQL then rejects the source upsert outside the fetch/parsing try, causing mapLimited and the entire update check to fail instead of recording this one source as failed and continuing with the others; reject or truncate oversized validators before persistence.
Useful? React with 👍 / 👎.
| // A result for another source answers a different question. | ||
| if (row?.sourceUrl !== component.sourceUrl || row.result === null) { | ||
| return notChecked; |
There was a problem hiding this comment.
Drop metadata from a previous update source
When a deployed plugin changes its update URL, this branch correctly discards the old result but returns notChecked with the old row's lastAttemptAt and lastError. If the previous URL's last request failed, the catalog therefore reports that the new source failed before it has ever been checked; clear the attempt/error metadata when row.sourceUrl !== component.sourceUrl.
Useful? React with 👍 / 👎.
| }; | ||
|
|
||
| const assertValid = (manifest: UpdateManifest): UpdateManifest => { | ||
| const parsed = parseUpdateManifest(JSON.parse(JSON.stringify(manifest))); |
There was a problem hiding this comment.
React Doctor · react-doctor/no-json-parse-stringify-clone (warning)
JSON.parse(JSON.stringify(x)) deep-clones by re-serializing: it is slow on large objects and silently drops undefined, functions, Date/Map/Set, and cyclic references. Use structuredClone(x).
Fix → Replace JSON.parse(JSON.stringify(value)) with structuredClone(value). It is faster and preserves Dates, Maps, Sets, and cyclic references.
| if (outcome.error !== null) { | ||
| // The failure is recorded; what was found before - and the versions it | ||
| // was found for - stays exactly as it was. | ||
| await db |
There was a problem hiding this comment.
React Doctor · react-doctor/async-await-in-loop (warning)
This for…of loop waits before starting the next iteration. If iterations perform independent asynchronous work, consider bounded concurrency; await syntax alone does not establish a speedup.
Fix → Consider concurrent calls only for independent asynchronous work. Shared queues or synchronous work may not benefit. Preserve resource limits, transaction ordering, and failure/cancellation semantics; observe all callback promises.
| for (const plugin of installed.plugins) { | ||
| const problem = pluginProblem( | ||
| plugin, | ||
| updates?.plugins.find(update => update.id === plugin.id), |
There was a problem hiding this comment.
React Doctor · react-doctor/js-index-maps (warning)
This gets slow as your list grows because array.find() runs inside a loop, so build a Map once before the loop for instant lookups
Fix → Build a Map once before the loop instead of calling array.find(...) inside it
| ): (typeof CHECK_ERRORS)[number] => | ||
| CHECK_ERRORS.find(entry => entry === error.code) ?? "network"; | ||
|
|
||
| const ResultLines = ({ |
There was a problem hiding this comment.
React Doctor · react-doctor/no-high-complexity-react-function (warning)
ResultLines has cyclomatic complexity 17, cognitive complexity 17, and maximum nesting depth 2, so its React logic is hard to understand and change. Extract independent branches into components or hooks.
Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.
| * Security findings are rendered apart, by `SecurityUpdates`, and never | ||
| * folded into "update available". | ||
| */ | ||
| export const PluginUpdateStatus = ({ |
There was a problem hiding this comment.
React Doctor · react-doctor/no-high-complexity-react-function (warning)
PluginUpdateStatus has cyclomatic complexity 18, cognitive complexity 15, and maximum nesting depth 2, so its React logic is hard to understand and change. Extract independent branches into components or hooks.
Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6ffbf7ae36
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| href: "/admin/core/debug/cron", | ||
| icon: <ClockIcon />, | ||
| permission: { module: "cron", permission: "can_view" }, |
There was a problem hiding this comment.
Gate moved search entries with the debug permission
For an administrator who has cron.can_view, queue.can_view, or system.can_view but lacks debug.can_view, these new search-only items are displayed, yet every destination is nested under the debug layout, whose loader rejects that user in tanstack/admin/debug/route.tsx:59 before the child permission is checked. This turns valid search results into links that consistently lead to an access-denied page; expose them only when the parent debug permission is also granted, or stop requiring that extra permission in the parent.
Useful? React with 👍 / 👎.
| return [ | ||
| check("vitnode-latest", "ok", { | ||
| key: "vitnode.up_to_date", | ||
| values: { version: installed.core.version }, | ||
| }), |
There was a problem hiding this comment.
Only report the core as current after an up-to-date result
When a successful core check returns package_missing, channel_missing, or unknown_channel, core.state is still checked, so execution reaches this unconditional success result and the Debug Panel claims the installed core is up to date. The same happens for a stale recomputed result that is not an available update. Only emit vitnode.up_to_date when core.result.status === "up_to_date" and the result is not stale; otherwise surface the unresolved or stale state.
Useful? React with 👍 / 👎.
Stage 2 of plugin management. Core and every plugin that declares
vitnode.updates.urlin its package.json are checked once a day (and ondemand) against public update manifests, never npm. Results appear in
AdminCP → System → Plugins; nothing is installed or changed.
releases, advisories), shared by the server and the release tooling
updatescarried through manifest.gen.ts and the frontend inventoryguards on every hop and at connect time, timeout, streamed size limit,
bounded concurrency, ETag/Last-Modified with 304 reuse
compatibility and an older compatible target; API and web app versions
evaluated separately
isolation, preserved findings and stale detection after redeploys
POST /admin/plugins/updates/check(plugins.can_check_updates), withexpiry, owner-only release and a manual cooldown
warnings, frontend inventory notice and a Check for updates button
update-manifestbranchafter npm publication; advisories come from security/advisories.json
Co-Authored-By: Claude Opus 5.5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01412WzAxDRk5MbUe4C2S2N1