feat(registry): profile fields end-to-end (license, authors, security, keywords, repo)#1112
Conversation
…, keywords, repo) Wires the emdash-plugin.jsonc profile block through the publish pipeline and renders it in the admin. CLI (@emdash-cms/plugin-cli): - New structured ProfileInput on publishRelease (multi-author, multi-security, name, description, keywords); release-level repo. - Flat ProfileBootstrap / --license / --author-* / --security-* kept working but deprecated; flags override the manifest and warn. - manifestToProfileInput translation. Admin (@emdash-cms/admin): - Detail page renders license (spdx-linked), keywords, authors, security contacts, repo link; browse card shows license. - Untrusted aggregator profile/release parsed defensively; external hrefs scheme-allowlisted; emails validated before mailto. Closes #1029
🦋 Changeset detectedLatest commit: dc3d525 The changes in this PR will be included in the next version bump. This PR includes changesets to release 15 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-i18n | dc3d525 | May 19 2026, 09:19 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | dc3d525 | May 19 2026, 09:20 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-perf-coordinator | dc3d525 | May 19 2026, 09:19 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | dc3d525 | May 19 2026, 09:21 PM |
Scope checkThis PR changes 726 lines across 10 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | dc3d525 | May 19 2026, 09:21 PM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
Pull request overview
This PR wires registry profile metadata from emdash-plugin.jsonc through the plugin publish pipeline and into the admin registry UI, expanding package profile/release publishing and display.
Changes:
- Adds structured
ProfileInputpublishing with richer profile fields and release-levelrepo. - Updates publish CLI manifest translation and deprecated flag handling.
- Renders license, keywords, authors, security contacts, and source links in admin registry views.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
packages/plugin-cli/src/publish/api.ts |
Adds structured profile input, profile record fields, and release repo support. |
packages/plugin-cli/src/manifest/translate.ts |
Translates normalized manifests into structured profile input. |
packages/plugin-cli/src/commands/publish.ts |
Uses manifest profile input, keeps deprecated flag overrides, and passes release repo. |
packages/plugin-cli/src/api.ts |
Exports the new ProfileInput type. |
packages/plugin-cli/tests/publish.test.ts |
Covers structured profile publishing and release repo behavior. |
packages/plugin-cli/tests/manifest-translate.test.ts |
Covers manifest-to-profile-input translation. |
packages/admin/src/components/RegistryPluginDetail.tsx |
Displays richer registry metadata with defensive parsing/safe links. |
packages/admin/src/components/RegistryBrowse.tsx |
Shows license on registry browse cards with safer profile parsing. |
.changeset/registry-profile-fields-cli.md |
Adds plugin CLI minor changeset. |
.changeset/registry-profile-fields-admin.md |
Adds admin minor changeset. |
Comments suppressed due to low confidence (1)
packages/admin/src/components/RegistryPluginDetail.tsx:151
- This untrusted array is iterated without applying the lexicon cap (security ≤8). A malicious or broken aggregator can return a very large list and force the admin page to spend significant time filtering/rendering it; slice to the expected maximum before processing.
const securityList = Array.isArray(profileRaw?.security)
? profileRaw.security.flatMap((c) => {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const keywordList = Array.isArray(profileRaw?.keywords) | ||
| ? profileRaw.keywords.filter((k): k is string => typeof k === "string" && k.length > 0) | ||
| : []; | ||
| const authorList = Array.isArray(profileRaw?.authors) | ||
| ? profileRaw.authors.flatMap((a) => { |
| function safeExternalHref(value: unknown): string | null { | ||
| if (typeof value !== "string" || value.length === 0) return null; | ||
| let parsed: URL; |
| * SPDX page URL for a license, or `null` when the value isn't a single | ||
| * SPDX identifier (compound expressions like "MIT OR Apache-2.0" and the | ||
| * literal "proprietary" have no canonical spdx.org page). | ||
| */ | ||
| /** |
…st boundary DiscoveryClient now lexicon-validates the embedded signed profile/release records (atcute safeParse), returning PackageProfile.Main | null / PackageRelease.Main | null instead of unknown. New Validated* view types. - Replaces the ad-hoc Array.isArray/typeof shape-parsing added for #1029 in the admin components with a single validated boundary. safeExternalHref / safeEmail are kept: the lexicon uri format permits javascript:, so validation is structural only and not an href-safety control. - Admin view types alias the registry-client Validated* types; wrapper as-casts removed. - Core install handler reads the typed release/profile; a non-conforming record (null) fails closed at the existing identity / artifact gates. - plugin-cli info/search drop now-redundant local re-validation. Validation is non-stripping (atcute is non-destructive; lexicon objects are open) and that contract is asserted in tests. Adversarial review (2 rounds) clean: no reintroduced XSS, core fails closed, typing sound. Refs #1029
…nt.call
DiscoveryClient now routes every aggregator XRPC call through
@atcute/client's schema-validating `.call()` instead of the
NSID-string `.get()`, which did no runtime validation. atcute now
validates request params and the response envelope against the
aggregator method's output lexicon; a non-conforming envelope throws
ClientValidationError. The existing per-record `validateProfile` /
`validateRelease` step still runs because the aggregator lexicon
intentionally types `profile` / `release` as `unknown` (verbatim
pass-through of signed publisher records under a different lexicon
namespace).
Side effects of the envelope-validation layer:
- A non-object `profile` / `release` from the aggregator now
throws at the call layer (lexicon `unknown` is "open object",
not "any value") instead of becoming `null` via the record
validator. Strictly stricter, fail-closed earlier.
- Request params (did/handle/slug/limit/cursor) are now validated
before the request fires. Defense-in-depth; existing call sites
already feed validated values.
Test fixtures updated for the now-active envelope validation: real
DASL CID, all required `packageView` / `releaseView` fields. No
public type changes.
Adversarial review clean (one deferred LOW: install handler surfaces
ClientValidationError under generic INSTALL_FAILED code — pre-existing
error-classification gap, belongs with #1036).
Refs #1029
|
/review |
| * SPDX page URL for a license, or `null` when the value isn't a single | ||
| * SPDX identifier (compound expressions like "MIT OR Apache-2.0" and the | ||
| * literal "proprietary" have no canonical spdx.org page). | ||
| */ |
There was a problem hiding this comment.
Nit — orphaned JSDoc. This block describes spdxLicenseHref ("SPDX page URL for a license…") but it sits directly above safeExternalHref (which has its own JSDoc immediately following on 487–494). spdxLicenseHref is defined later at line 522 with no doc comment of its own. Either delete this stale block or move it down next to spdxLicenseHref. Misleading to future readers as-is.
|
Review posted successfully with one inline nit comment. SummaryReviewed PR #1112. The PR is in solid shape:
Only one nit posted: lines 482–486 of |
…cycle (#1114) * feat(registry): uninstall, update, update-check handlers + admin lifecycle Implements #1036: registry plugins now have a full lifecycle parallel to marketplace, removing the 'uninstall not yet available' band-aid in PluginManager. Server: - handleRegistryUninstall: deletes the R2 bundle, drops _plugin_state, optionally clears _plugin_storage rows. Refuses non-registry sources. - handleRegistryUpdate: re-runs the install pipeline at a newer version, diffing capabilities + public-route visibility against the currently installed bundle. CAPABILITY_ESCALATION and ROUTE_VISIBILITY_ESCALATION gate widened permissions behind confirmCapabilityChanges / confirmRouteVisibilityChanges, mirroring marketplace exactly. - handleRegistryUpdateCheck: scans installed registry plugins, queries the aggregator's getLatestRelease for each, returns the version diff. Per-plugin aggregator failures don't blank the list. - POST /_emdash/api/admin/plugins/registry/:id/uninstall - POST /_emdash/api/admin/plugins/registry/:id/update - GET /_emdash/api/admin/plugins/updates is now cross-source: runs marketplace + registry checks in parallel, isolates failures, and logs structured errors. Wraps the merged items in the standard { data: { items } } envelope. - Marketplace's diffCapabilities + diffRouteVisibility are now exported so the registry handler can reuse them without duplication. Admin: - updateRegistryPlugin / uninstallRegistryPlugin client functions. - PluginManager dispatches mutationFn by plugin.source so registry plugins flow through the new endpoints; uninstall button enabled for any sandboxed source. Deferred from #1112 (folded in): - Install handler now classifies aggregator failures as AGGREGATOR_RESPONSE_INVALID (ClientValidationError) and AGGREGATOR_HTTP_ERROR (ClientResponseError) instead of folding both into INSTALL_FAILED. The same classification applies to update and update-check. Deferred from #1011 (backfilled): - makeRegistryPluginId: format, determinism, distinctness across publishers + slugs, and a 10 000-pair collision check. - verifyChecksum: hex + multibase paths, algorithm-mismatch, malformed. - Lifecycle handler error paths. Out of scope (inherited from marketplace, will fix together): - Concurrent update + downgrade race where a fire-and-forget cleanup of the previous version can delete the now-current bundle. - Update consent dialog architecturally bypasses the server's escalation gate (mutationFn pre-confirms; the dialog shows already-granted caps with newCapabilities: []). The server gate is correct; the client never reaches it. Same shape in marketplace. Refs #1036 * docs(changeset): rewrite for users, not implementation * fix(registry): address review feedback - PluginManager: extend the 'Check for updates' button gate from hasMarketplacePlugins to hasUpdatableSources so registry-only sites can actually trigger the merged update check. - handleRegistryUninstall: drop the bare try/catch around the _plugin_storage delete. A DELETE with zero matches doesn't throw, so the bare catch only ever masked real DB errors while still reporting dataDeleted: false. Real errors now propagate to the outer catch and surface as UNINSTALL_FAILED. - handleRegistryUpdate: remove the dead 'err instanceof SsrfError' branch from the catch (assertSafeArtifactUrl wraps SsrfError in a plain Error before rethrowing, so the branch was unreachable). - registry-handlers test header: drop the update-check coverage claim to match the actual test surface. Reworks the update-consent flow to actually consult the server's escalation gate instead of pre-confirming: - apiError / unwrapResult now plumb error.details through to the response body. The CAPABILITY_ESCALATION and ROUTE_VISIBILITY_ ESCALATION responses now carry their diff to the client. - New RegistryUpdateEscalationError carries the diff. updateRegistryPlugin parses CAPABILITY_ESCALATION / ROUTE_VISIBILITY_ESCALATION 403s and throws it instead of a generic Error. - PluginManager preflights the registry update without confirm flags; on escalation the consent dialog opens populated with the real capabilityChanges.added and newlyPublicRoutes; the user's confirm re-calls with confirmCapabilityChanges + confirmRouteVisibilityChanges set. Iterative escalations (capability then route) re-open the dialog with the new diff. - CapabilityConsentDialog: new newlyPublicRoutes prop renders the public-route diff alongside the capability diff. Marketplace's update path is unchanged in this PR (it still pre-confirms; same WS3 TODO it has always had). Registry no longer inherits that bypass. * fix(registry): address second review round - HIGH (consent): handleUpdateConfirm now only sets the confirm flag that matches the escalation the dialog actually displayed. Sending both unconditionally auto-confirmed route-visibility changes the user was never shown when capability escalation came first. The iterative re-open promised in the previous commit now actually happens: cap confirm → server returns ROUTE_VISIBILITY_ESCALATION → onError repopulates the dialog with the route diff → user confirms the new view → second roundtrip sends both flags. - handleRegistryUninstall: restore a narrow try/catch around the _plugin_storage cleanup, logging the error and continuing with the state-row delete. Without it, a transient DB failure during the optional cleanup orphaned the state row pointing at an already- deleted bundle. Honest dataDeleted=false plus telemetry instead of swallowed silence. - errors.ts: AGGREGATOR_RESPONSE_INVALID and AGGREGATOR_HTTP_ERROR now map to 502 Bad Gateway (added to ErrorCode and to mapErrorStatus). - updates.ts: reword the header — the pluginId prefix is not a reliable source discriminator; consumers correlate via the plugin list's field. * fix(registry): address third review round MED — uninstall ordering trades retriability for orphans (regression introduced last commit). Reordered the uninstall steps so the most failure-prone work runs first: _plugin_storage cleanup → bundle delete → state row delete. A transient DB error during the optional storage cleanup now cascades to the outer catch with state row and bundle intact, so the admin can retry safely (bundle delete is idempotent on misses). Replaces the previous swallow-and-continue, which orphaned _plugin_storage rows forever when the state row got deleted underneath them. LOW — split AGGREGATOR_HTTP_ERROR's 502-everywhere mapping. A user typo (publisher/slug doesn't exist) was surfacing as a 502 with a "upstream broken" semantic, firing on the operator's 5xx alerting. The three handler catches now map err.status === 404 to a new AGGREGATOR_NOT_FOUND code (404), leaving AGGREGATOR_HTTP_ERROR (502) for the actually-upstream-broken case.
What does this PR do?
Implements #1029 (child of the registry roadmap umbrella #1026): wires the
emdash-plugin.jsoncprofile block end-to-end — through the@emdash-cms/plugin-clipublish pipeline and into the@emdash-cms/adminUI.CLI (
@emdash-cms/plugin-cli):ProfileInputonpublishRelease— multi-author (1–32), multi-security (1–8),name,description,keywords— plus release-levelrepowritten to the release record.name/description/keywordsadded to the profile record shape;repoto the release record shape.ProfileBootstrapand the--license/--author-*/--security-*flags still work (acceptance criterion 4) but are deprecated: marked in CLI help, emit a deprecation warning, and override the manifest when both are present.manifestToProfileInputtranslation; deprecatedmanifestToProfileBootstrapretained for the flat path.Admin (
@emdash-cms/admin):repois read from the release record (it's arelease.jsonfield; the profile lexicon has norepo).Security hardening (from adversarial review): the aggregator-supplied
profile/releaserecords are untrusted (unknown). They are now parsed defensively —Array.isArray/typeofguards so a string-as-authorscan't crash the page, externalhrefs scheme-allowlisted tohttp(s)(blocks storedjavascript:XSS), and emails validated (nomailto:query smuggling) before use.Closes #1029
Discussion: #296
Deferred (pre-existing, not regressions — tracked for follow-up)
ProfileBootstrapand reported those too). Proper fix is Registry CLI: update-profile command (edit an existing profile without a new release) #1032 (update-profile).--author-*flags replace the whole author list and defaultnameto"unknown"— unchanged deprecated-path behavior.name/description/keywordssurface as a raw lexicon-validator dump rather than a friendly message (formatValidationIssues, pre-existing). No crash.Type of change
Checklist
pnpm typecheckpassespnpm lintpasses (touched files clean; pre-existing repo baseline of 56 unrelated diagnostics unchanged)pnpm testpasses (plugin-cli 264, admin 879)pnpm formathas been runt; nomessages.pochanges included)AI-generated code disclosure
Screenshots / test output
Adversarial review (Claude Opus 4.7 sub-agent, two rounds): a CRITICAL stored-XSS (
javascript:href from untrusted aggregator data) and a HIGH crash-on-garbage were found and fixed; a LOWmailto:query-smuggling was found in the fix and also closed; re-review confirmed clean.