Skip to content

feat(registry): profile fields end-to-end (license, authors, security, keywords, repo)#1112

Merged
ascorbic merged 3 commits into
mainfrom
feat/registry-profile-fields
May 19, 2026
Merged

feat(registry): profile fields end-to-end (license, authors, security, keywords, repo)#1112
ascorbic merged 3 commits into
mainfrom
feat/registry-profile-fields

Conversation

@ascorbic
Copy link
Copy Markdown
Collaborator

What does this PR do?

Implements #1029 (child of the registry roadmap umbrella #1026): wires the emdash-plugin.jsonc profile block end-to-end — through the @emdash-cms/plugin-cli publish pipeline and into the @emdash-cms/admin UI.

CLI (@emdash-cms/plugin-cli):

  • New structured ProfileInput on publishRelease — multi-author (1–32), multi-security (1–8), name, description, keywords — plus release-level repo written to the release record.
  • name/description/keywords added to the profile record shape; repo to the release record shape.
  • The flat ProfileBootstrap and 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.
  • New manifestToProfileInput translation; deprecated manifestToProfileBootstrap retained for the flat path.

Admin (@emdash-cms/admin):

  • Detail page renders license (linked to spdx.org for single SPDX identifiers), keyword chips, all authors, all security contacts, and a "View source" repo link.
  • Browse card shows the license alongside the description.
  • repo is read from the release record (it's a release.json field; the profile lexicon has no repo).

Security hardening (from adversarial review): the aggregator-supplied profile/release records are untrusted (unknown). They are now parsed defensively — Array.isArray/typeof guards so a string-as-authors can't crash the page, external hrefs scheme-allowlisted to http(s) (blocks stored javascript: XSS), and emails validated (no mailto: query smuggling) before use.

Closes #1029

Discussion: #296

Deferred (pre-existing, not regressions — tracked for follow-up)

  • The "ignored on subsequent publish" warning now lists manifest-derived profile field names. This is honest (those fields are ignored until profile editing exists) and matches prior behavior (the old code folded the manifest into the flat ProfileBootstrap and reported those too). Proper fix is Registry CLI: update-profile command (edit an existing profile without a new release) #1032 (update-profile).
  • Partial --author-* flags replace the whole author list and default name to "unknown" — unchanged deprecated-path behavior.
  • Over-cap manifest name/description/keywords surface as a raw lexicon-validator dump rather than a friendly message (formatValidationIssues, pre-existing). No crash.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes (touched files clean; pre-existing repo baseline of 56 unrelated diagnostics unchanged)
  • pnpm test passes (plugin-cli 264, admin 879)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (Lingui t; no messages.po changes included)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: Marketplace Discussion #296

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.7 (Claude Code)

Screenshots / test output

plugin-cli:  Test Files 16 passed (16)   Tests 264 passed (264)
admin:       Test Files 62 passed (62)   Tests 879 passed (879)

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 LOW mailto: query-smuggling was found in the fix and also closed; re-review confirmed clean.

…, 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
Copilot AI review requested due to automatic review settings May 19, 2026 20:08
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 19, 2026

🦋 Changeset detected

Latest commit: dc3d525

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 15 packages
Name Type
@emdash-cms/registry-client Minor
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/plugin-cli Minor
@emdash-cms/cloudflare Minor
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 19, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-i18n dc3d525 May 19 2026, 09:19 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 19, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs dc3d525 May 19 2026, 09:20 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 19, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-perf-coordinator dc3d525 May 19 2026, 09:19 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 19, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache dc3d525 May 19 2026, 09:21 PM

@github-actions
Copy link
Copy Markdown
Contributor

Scope check

This 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.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 19, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground dc3d525 May 19 2026, 09:21 PM

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 19, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1112

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1112

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1112

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1112

emdash

npm i https://pkg.pr.new/emdash@1112

create-emdash

npm i https://pkg.pr.new/create-emdash@1112

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1112

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1112

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1112

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1112

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1112

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1112

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1112

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1112

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1112

commit: dc3d525

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ProfileInput publishing with richer profile fields and release-level repo.
  • 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.

Comment on lines +139 to +143
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) => {
Comment on lines +510 to +512
function safeExternalHref(value: unknown): string | null {
if (typeof value !== "string" || value.length === 0) return null;
let parsed: URL;
Comment on lines +498 to +502
* 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
@ascorbic ascorbic requested a review from Copilot May 19, 2026 21:26
@ascorbic
Copy link
Copy Markdown
Collaborator Author

/review

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

* 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).
*/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 19, 2026

Review posted successfully with one inline nit comment.

Summary

Reviewed PR #1112. The PR is in solid shape:

  • Security hardening is thorough: lexicon validation at the DiscoveryClient boundary (with tests covering non-conforming records → null), safeExternalHref scheme allow-list (http/https only — blocks the javascript: XSS the author called out), and safeEmail that disallows ?, &, =, %, / to block mailto: query smuggling.
  • Copilot's suggestions on iteration caps and URL max-length are moot: the lexicon validator enforces authors ≤ 32, security ≤ 8, keywords ≤ 5, and url maxLength 1024 at the boundary — non-conforming records arrive as null. The PR's tests explicitly cover that path.
  • Identity-mismatch checks fail closed when signedRelease is null: signedRelease?.package !== slug correctly resolves to undefined !== slug → true. Confirmed.
  • RTL/dark-mode/Lingui rules followed in the new admin JSX.

Only one nit posted: lines 482–486 of RegistryPluginDetail.tsx are an orphaned JSDoc block describing spdxLicenseHref but sit directly above safeExternalHref, separated by another JSDoc. Easy delete-or-move.

github run

@ascorbic ascorbic merged commit 3756168 into main May 19, 2026
44 checks passed
@ascorbic ascorbic deleted the feat/registry-profile-fields branch May 19, 2026 21:32
@emdashbot emdashbot Bot mentioned this pull request May 19, 2026
ascorbic added a commit that referenced this pull request May 20, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Registry: license, authors, security contacts, keywords, repo end-to-end

2 participants