Commit Graph

28 Commits

Author SHA1 Message Date
emdashbot[bot] 4c3edadeec ci: release (#2419)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-18 13:26:49 +01:00
Matt Kane cd4268d1d1 fix(security): prevent image parser denial of service (#2504)
* fix(security): prevent image parser denial of service

* test(security): cover all patched parser paths

* chore(build): preserve patched dependency rationale
2026-08-16 17:35:49 +00:00
Scott Buscemi 5f0ef18d14 Revert "feat: cache purge API (object cache + native Workers Caching)" (#2281)
Reverts e88655417d (#2275), which was merged before maintainer approval.
2026-07-30 11:17:24 +01:00
Scott Buscemi e88655417d feat: cache purge API (object cache + native Workers Caching) (#2275)
* feat: add object-cache purge API and cache:purge plugin capability

Admins and sandboxed plugins can clear CMS object-cache namespaces
(KV/memory) via GET/POST /_emdash/api/admin/cache/object and
ctx.cache. Block Kit buttons gain optional disabled and title fields
for clearer troubleshooting UI.

* style: format

* feat: add Workers Cache purge API alongside object cache

Admins and plugins with cache:purge can clear edge-cached pages via
GET/POST /_emdash/api/admin/cache/workers and ctx.cache.purgeWorkersCache()
(Cloudflare purge_everything using CF_ZONE_ID + CF_CACHE_PURGE_TOKEN).

* feat: purge Workers Cache via native cache.purge()

Replace zone REST purge (CF_ZONE_ID + token) with cloudflare:workers
cache.purge({ purgeEverything: true }). Status is configured when the
native API is available — no secrets required.

* fix(core): resolve Workers Cache purge via virtual module

Dynamic import of cloudflare:workers from core failed under Vite.
Expose cache through virtual:emdash/workers-cache (same pattern as env
and waitUntil) so status/purge work on the Cloudflare adapter.

* feat: Workers Cache path-prefix purge

POST /admin/cache/workers and ctx.cache.purgeWorkersCache() accept
optional pathPrefixes (paths or full URLs, normalized). Empty input
still purges everything via cache.purge.

* fix: lint workers-cache handlers and marketplace capability list

Move URL regex to module scope, drop redundant unknown union, rename
shadowed Tooltip render prop, and include cache:purge in
CAPABILITY_LABELS contract test.

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
2026-07-29 16:48:58 -05:00
emdashbot[bot] 3c555590ab ci: release (#1930)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-21 11:26:37 +01:00
jc e52dea9b72 feat(plugins): allow explicit MCP tool declarations (#2002)
* feat(plugins): add explicit MCP tool declarations

* fix(plugins): harden MCP request and consent handling

* fix(admin): send marketplace capability consent flag
2026-07-21 07:02:20 +01:00
emdashbot[bot] baf4d8394f ci: release (#1871)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-10 17:29:37 +01:00
Matt Kane b530d4f1ea chore(deps): update oxlint 1.73 and oxlint-tsgolint 0.24 (#1919)
Bumps oxlint 1.71.0 to 1.73.0 and oxlint-tsgolint 0.23.0 to 0.24.0.

The stricter no-unnecessary-type-assertion rule flagged 82 redundant
assertions. Removed them via autofix, dropped the now-dangling
no-unsafe-type-assertion disable comments, removed the type-only imports
left unused, and added justified no-base-to-string suppressions at the
few sites where a removed assertion had been narrowing an unknown scalar
for String().

Compile-time only; emitted output is unchanged.
2026-07-10 15:19:41 +00:00
Kevin Kyburz 7c5de08f63 feat(plugins): read-only taxonomy access via new taxonomies:read capability (#1719)
* feat(plugins): read-only taxonomy access via new taxonomies:read capability

* test(cloudflare): cover PluginBridge taxonomy methods

Review follow-up: capability enforcement, locale/taxonomy filter SQL
wiring, and D1 row mapping (JSON parsing, int→bool, nullable columns)
for taxonomyList/taxonomyTerms/taxonomyEntryTerms.

* test/fix: review follow-ups for taxonomies:read

Guard the in-process collections JSON parse like both bridges (an
in-process plugin no longer crashes on malformed definition data), and
extend the workerd conformance suite to taxonomy/terms and
taxonomy/entryTerms: capability gating, locale filtering, data JSON
parsing, and the pivot join on translation_group.
2026-07-10 14:21:14 +01:00
Matt Van Horn d9d7cd545b docs: document required build step before plugin-cli typecheck (#1642)
* docs: document required build step before plugin-cli typecheck

* chore: add empty changeset for docs-only build-step note (#1205)

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-06-30 12:24:16 +01:00
emdashbot[bot] 7f143184d9 ci: release (#1449)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-16 17:57:16 +01:00
Matt Kane b01aa9bbb4 fix(registry): declaredAccess as the plugin trust contract (#1461)
* feat(registry): declaredAccess vocabulary + total capability converters

Adds the email.events/email.transport, page.fragments and users facets to the releaseExtension lexicon, and the canonical DeclaredAccess type plus the two total converters (capabilitiesToDeclaredAccess / declaredAccessToCapabilities) in plugin-types. A round-trip property test over all 1152 implication-closed capability states proves the two representations are isomorphic -- the guard whose absence caused the consent-drift bug. Adds an optional declaredAccess field to PluginManifest ahead of making it the authoritative wire contract.

* feat(registry): emit declaredAccess in the bundle manifest; reconcile at parse

The bundler (both extractManifest copies) now writes declaredAccess into manifest.json alongside the capability list. The manifest Zod schema accepts it, and reconcileManifestAccess -- applied at the two bundle-parse chokepoints (extractBundle, loadBundleFromR2) that both marketplace and registry installs flow through -- makes declaredAccess authoritative: capabilities/allowedHosts are re-derived from it so runtime enforcement always matches the trust contract. A pre-migration bundle without declaredAccess has it derived from the legacy list (back-compat). Runtime enforcement code is untouched. plugin-cli 391 + core plugins 596 tests green.

* feat(registry): publish carries manifest.declaredAccess verbatim; drop buildDeclaredAccess

The release record's trust extension now carries the bundle manifest's declaredAccess directly (falling back to deriving it from the legacy capability list for pre-migration tarballs), replacing the lossy buildDeclaredAccess/findUnmappedCapabilities that dropped hook-registration capabilities. Adds publish-side regression coverage for the email-transport+events+network plugin that caused DECLARED_ACCESS_DRIFT.

* feat(registry): admin consent derives capabilities via the shared converter

RegistryPluginDetail now builds the consent list from the record's declaredAccess using the same total declaredAccessToCapabilities from @emdash-cms/plugin-types that the bundler and runtime use, replacing a component-local flattener that produced category:operation strings and silently dropped hook-registration capabilities. The consent list now equals what the install handler enforces, so a plugin declaring email.transport/events installs cleanly. Adds @emdash-cms/plugin-types as an admin dependency to keep one source of truth rather than a third browser-side reimplementation.

* chore: changeset for declaredAccess trust contract

* fix(registry): close two declaredAccess permission holes found in review

Finding 1 (network widening): an empty allowedHosts array decoded to network:request:unrestricted -- the most-restrictive spelling granted the most. declaredAccessToCapabilities now treats an absent allowedHosts as unrestricted and a present (even empty) list as host-restricted; the bundle schema also rejects an empty array to match the record lexicon. Finding 2 (consent bypass): the install consent gate compared capability strings, which discard host scope, so a record advertising one allowedHosts and a bundle enforcing another both reduced to network:request and passed. handleRegistryInstall now refuses any install whose bundle declaredAccess does not match the signed record's enforced access -- capabilities AND hosts. Round-trip and integrity regressions added.

* style: format

* fix(registry): apply the declaredAccess integrity gate on update too

handleRegistryUpdate diffed capabilities for escalation but never checked the new bundle's declaredAccess against the new signed record's -- so an update changing only host scope (api.good.com -> evil.com) kept the capability set identical, passed the escalation diff, and installed a bundle enforcing a scope the record never advertised. Adds the same enforcedAccessEqual gate handleRegistryInstall uses. Caught by emdashbot review of #1461.

* refactor(registry): localize the manifest cast; drop review-thread comments

reconcileManifestAccess now returns PluginManifest, moving the as-unknown bridge (needed because Zod types Block Kit admin elements as unknown) into the helper so both parse sites read cleanly. Also rewrites test comments that referenced the review thread and bug history to state the invariant under test instead.

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
2026-06-16 10:36:01 +01:00
emdashbot[bot] 73b5cf486c ci: release (#1283)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-03 23:41:53 +01:00
emdashbot[bot] 204b2f1931 ci: release (#1209)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-01 20:46:38 +01:00
Matt Kane d2f26792bc feat(registry): display long-form plugin info sections + SBOM/lastUpdated (#1253)
* refactor(admin): extract shared renderMarkdown into lib/markdown

Lifts the audited marked+DOMPurify renderer out of MarketplacePluginDetail
into a shared module so the registry plugin detail page (#1030 sections) can
reuse one sanitizer. Adds an XSS regression suite covering script/raw-HTML/
javascript:/data: links, foreign-host images, and inline styles. No behavior
change to the marketplace readme rendering.

* feat(registry): profile sections (description/installation/faq/changelog/security) in CLI publish

* feat(registry): admin sections, SBOM badge, lastUpdated, verified tooltip

* feat(registry): make profile sections editable via update-package

sections becomes a first-class editable profile field rather than
publish-only/preserved: PackageUpdateInput carries it, the generic diff
loop writes it (manifest-absent = preserve, like keywords), and the
dry-run diff truncates long values so a sections change stays readable.

* fix(registry): address PR review feedback on sections

- validate now resolves section file-refs offline (reads files, enforces
  the byte/grapheme caps + path-escape guard), so it matches the documented
  'fails locally' contract instead of deferring those checks to publish.
- markdown renderer.link renders the link's inline tokens instead of the raw
  markdown source, so [**bold** text](url) formats correctly; DOMPurify still
  re-sanitizes the output. Fixes a bug inherited from the marketplace renderer.
- drop the setState-during-render section reconcile; activeSection's fallback
  to the default already guarantees the Tabs value has a matching pane.
2026-06-01 14:27:38 +01:00
Matt Van Horn 245f8dc221 plugin-cli: fix emdash-plugin build ESM path resolution on Windows (#1247)
Closes #1236

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-06-01 06:41:33 +01:00
Matt Kane 60c0b2eeab Registry: env constraints (env:emdash, env:astro) end-to-end with admin compatibility warning (#1238)
* feat(registry): release env requires + admin compatibility gate

Plugins published to the experimental registry can declare release-level
environment constraints. A manifest's `requires` block (e.g.
`{ "env:emdash": ">=1.0.0", "env:astro": ">=4.16" }`) is validated at
publish time and written into the release record.

- registry-client: new dependency-free `./env` module shared by the CLI,
  server, and admin — `parseRequires` (guards the lexicon-`unknown`
  value), `isValidVersionRange`, `satisfiesRange`, and
  `checkEnvCompatibility` over a focused semver-range grammar
  (comparators, caret, tilde, partial versions, wildcard, AND sets).
- plugin-cli: `RequiresSchema` on the manifest, threaded release-level
  into `publishRelease` (never via the profile input); JSON Schema
  regenerated.
- core: capture the host Astro version in `astro:config:setup` and
  surface it (with EmDash VERSION) on the admin manifest. New
  `assertEnvCompatible` gate refuses incompatible install AND update
  with `ENV_INCOMPATIBLE` (409), placed after yank-check, before the
  artifact fetch.
- admin: RegistryPluginDetail reads host versions, renders a localized
  compatibility warning and disables Install when unsatisfied.

Closes #1031.

* fix(registry): apply adversarial review findings for env requires

Share the version->env:* host map between server and admin via a single
hostEnvFromVersions helper, dropping the admin's redundant /manifest fetch
and the duplicated dev-skip rule (admin now derives host env from the
manifest query the shell already runs).

Log skipped env constraints server-side (findSkippedEnvConstraints) so a
host version the gate can't evaluate is observable rather than a silent
bypass. Cover the gate end-to-end through handleRegistryUpdate with a
mocked DiscoveryClient, asserting ENV_INCOMPATIBLE aborts before any
artifact fetch. Document the accepted, more-permissive prerelease range
semantics relative to node-semver.

* refactor(registry): use node-semver for env requires range evaluation

Replace the hand-rolled range evaluator in registry-client's env module
with the semver package. Adds || union support and node-semver prerelease
gating; satisfiesRange passes includePrerelease so a prerelease host build
is evaluated by precedence rather than excluded from release-only ranges
(a prerelease host is not a definite mismatch). isValidVersionRange (shared
by the publish-time RequiresSchema) and the fail-open gate semantics are
unchanged.

* refactor(registry): drop redundant buildHostEnv wrapper

buildHostEnv was a pure pass-through to hostEnvFromVersions (the shared
registry-client/env helper). The install/update routes now call
hostEnvFromVersions directly, and its duplicate dev-skip/astro-omit test
block is removed (covered by registry-client's env tests).

* refactor(registry): nest requires under release.requires

Aligns env constraints with release.artifacts (merged in #1239) under a
single release block, matching the RFC. repo stays top-level (documented
legacy for backwards compatibility). Publish reads manifest.release.requires;
the normalised manifest keeps a flat requires field.
2026-05-31 14:33:00 +00:00
Matt Kane 1a4918ff98 Registry: icon, screenshot, banner artifacts end-to-end (#1239)
* feat(registry): icon / screenshot / banner artifacts end-to-end

Wire release.artifacts.{icon,screenshot,banner} through publish and admin.

CLI:
- Manifest schema gains a `release.artifacts` block: icon/banner as single
  `{ file }` refs, screenshot as an array. JSON Schema regenerated.
- `publish` resolves each ref relative to the manifest, measures dimensions
  and content type via image-size, uploads to `--artifact-base-url`, and
  embeds `{ url, checksum, contentType, width, height, lang? }` in the
  release. The lexicon types `screenshot` as a single artifact, so the first
  screenshot uses that slot and extras ride in `x-screenshot-N` custom keys.
- image-size catalog-pinned.

Server:
- New admin proxy `GET /registry/artifact?url=` for publisher-supplied image
  URLs. Applies SSRF defences via assertSafeArtifactUrl (re-validating each
  redirect hop), enforces an image content-type allowlist, caps the body,
  and serves back with `private, no-store` plus attachment + sandbox CSP so
  a navigated SVG can't execute in the admin origin.

Admin:
- RegistryPluginDetail renders the icon, banner, and a screenshot gallery
  through the proxy. Every image URL goes through artifactProxyUrl (scheme
  allow-list) before the proxy.

* fix(registry): collision-free artifact URLs and tighter publish guards

Prefix each uploaded artifact URL with its role/index slot
(icon-, banner-, screenshot-N-) so two refs that share a basename in
different source directories no longer collapse to one upload target.
Previously two screenshots named shot.png in light/ and dark/ both
mapped to <base>/<slug>/<version>/shot.png; the second PUT overwrote
the first and both records pointed at the same URL.

Also tighten the publish-side path-escape guard to reject the `..`
segment precisely (plus absolute paths) instead of any relative path
beginning with two dots, which false-positived filenames like
`..config.png`. Drop image/avif from the proxy allowlist so the
served content types match what the CLI can actually produce.

Add tests: same-basename screenshots/icon get distinct URLs, a
two-dot filename is accepted, and a streamed body with no
content-length that exceeds the cap is rejected with 413 via
readCapped.

* refactor(registry): first-class screenshots array, drop x-screenshot-N

The #1033 implementation stored extra screenshots under x-screenshot-N
custom artifact keys, which is not FAIR-aligned. FAIR's artifacts map
allows an artifact value to be a list of objects; model screenshots as
a first-class array end to end.

- lexicon: artifacts.screenshot (single ref) -> screenshots (array of
  #artifact, maxLength 8); regenerate atcute types
- plugin-cli manifest schema: release.artifacts.screenshot -> screenshots;
  regenerate JSON schema
- publish path: write artifacts.screenshots as an array, drop the
  x-screenshot-N spillover; keep the collision-free slot-prefixed upload
  URL scheme (screenshot-N-<filename>)
- admin: read the screenshots array directly, drop x-screenshot-N
  collection and numeric ordering

* chore(registry): align bundle screenshot cap (5 -> 8) with the screenshots array cap

The bundle path's MAX_SCREENSHOTS governed a separate ingestion route
from the lexicon/manifest screenshots array (capped at 8). Align them so
all screenshot caps agree.

* refactor(registry): resolve artifact proxy URLs from the release record; forbid SVG, allow AVIF

The artifact proxy no longer accepts a caller-supplied URL. The client
addresses an artifact by coordinates (did, slug, version, kind, index); the
server resolves the declared URL from the validated release record, so the
proxy can only ever fetch a publisher-declared artifact. SSRF defences
remain as a second layer on the resolved URL (incl. every redirect hop).
SVG is dropped from both the proxy allowlist and the publish CLI (active
content); AVIF is added end-to-end.
2026-05-31 15:03:15 +01:00
emdashbot[bot] 139a4d5c96 ci: release (#1123)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-28 12:01:24 +01:00
Matt Kane cf3c706a65 feat(plugin-cli): add update-package command (#1126)
* feat(plugin-cli): add update-profile command (#1032)

Lets publishers edit an already-published profile (license, authors,
security contacts, name/description/keywords) without cutting a new
release. Dry-run by default; `--yes` writes via putRecord and bumps
lastUpdated. Refuses to change the slug, and surfaces a clear rename
warning when the manifest slug has drifted from the published one.

Closes the gap from the publish flow's "Profile updates aren't supported
yet" warning, which now points users at this command.

* refactor(plugin-cli): rename update-profile → update-package

"Profile" overloaded with the publisher's atproto profile
(app.bsky.actor.profile / src/profile.ts). The command operates on
com.emdashcms.experimental.package.profile — the per-package metadata
record — so "package" describes the user-facing surface without the
clash.

Renames the command, its api module, exported types
(PackageUpdateInput, UpdatePackageError, etc.), error codes
(PACKAGE_NOT_FOUND, PACKAGE_INVALID), and updates the publish warning.
The underlying record is still a profile record; docstrings keep that
term where it refers to the lexicon shape.

* fix(plugin-cli): address review on update-package

Drop UpdatePackageOptions.did — derive from publisher.did so the two
can't disagree (Copilot).

POSSIBLE_RENAME now lists every other package the publisher owns rather
than singling out an arbitrary one, so a publisher with multiple plugins
doesn't see a misleading rename pointer at an unrelated slug (Copilot).

Flip the optional-field policy: missing from manifest = preserve
existing value, matching `publish` semantics. Removing `description`
or `keywords` by accident no longer wipes the published value
(ask-bonk).

Add atproto swapRecord optimistic concurrency: PublishingClient gains
swapRecord on putRecord/unsafePutRecord; update-package threads
existing.cid through and surfaces concurrent edits as STALE_RECORD
instead of silently overwriting (ask-bonk).

Rethrow auth/permission errors from the sibling-scan path so an
expired token surfaces as itself rather than a misleading
PACKAGE_NOT_FOUND (ask-bonk).

Reword LEXICON_VALIDATION_FAILED to name caller-input length/grapheme
caps as the likely cause rather than blaming update-package (ask-bonk).

Update --json help text to match the actual output shape (Copilot).

Extract redirectConsolaToStderr into cli-output.ts, shared between
publish and update-package (Copilot).
2026-05-22 17:20:07 +01:00
Matt Kane 463c7a2303 refactor(plugin-cli): validate probed plugin shape with Zod (#1145)
* refactor(plugin-cli): validate probed plugin shape with Zod

Replaces the hand-rolled `if`/`throw` chain in `probeAndAssemble`
with a single Zod schema (`ProbedDefaultSchema` in
`probe-schema.ts`) and translates Zod issues back into the same
`BuildPipelineError` format plugin authors already see. Removes the
last 9 `no-unsafe-type-assertion` warnings in `pipeline.ts` and
clears the file's lint debt.

Key design choices:

- `normaliseEntry` (preprocess) maps bare-function hooks/routes to
  `{ handler }` so the schema only validates one shape per entry.
- Only plain objects (prototype is `Object.prototype` or null) reach
  per-field validation; exotic objects (Date, RegExp, Promise, class
  instances) are reduced to `null` so the entry-shape error fires
  instead of a misleading 'missing handler' message.
- Non-record outer collections (`hooks: []`, `hooks: null`, etc.)
  coerce to an empty record so the build doesn't fail on the kind of
  shapes the original `Object.keys` iteration silently no-op'd on.
- A `safeStringify` helper survives BigInts, cycles, functions, and
  symbols when embedding the offending value in the error message.

New tests in `probe-schema.test.ts` (32 cases) lock in the error-
message contract: code, exact message strings for the documented
field violations, and behavioural choices around exotic values.

* chore: add changeset
2026-05-22 16:00:15 +01:00
Matt Kane d4377338fc chore: clean up oxlint warnings across packages (#1140)
* chore: clean up oxlint warnings across packages

Addresses 47 of 56 type-aware lint warnings, leaving only the
plugin-cli pipeline.ts unsafe assertions which warrant a separate
Zod-driven PR.

Changes by category:

- preserve-caught-error: add `cause` to thrown error in registry handler
- no-unused-vars: drop unused `Link` import, prefix unused e2e vars
- no-shadow: rename locals shadowing Lingui macros (`msg`, `plural`)
- no-unnecessary-type-assertion: remove redundant casts and `!` where
  prior `typeof` checks or tsconfig settings already narrow the type
- atproto-test-utils: add to test-override file glob (test factories
  legitimately produce branded types)
- contentful-to-portable-text: add runtime type guards in types.ts
  (`isContentfulLinkPayload`, `isContentfulSysEnvelope`,
  `parseAssetFile`, `getStringField`, `getRecordField`) and use them
  throughout, replacing scattered `as` casts with validated narrowing
- adapt-sandbox-entry: extract `normalizeRouteEntry` helper, simplify
  headers normalization (`ctx.request` is always a real Request in the
  in-process adapter), narrow remaining boundary casts to single
  disable comments with rationale
- admin/api/registry: use the shared `parseApiResponse` helper for
  install endpoint; localStorage cache parse uses a type guard

* test: drop unused byline IDs in bylines.spec.ts

The `_firstBylineId` / `_secondBylineId` names read as if the values
themselves are unused, but the test actually relies on the
`createByline` side effects (the bylines need to exist before the
combobox can find them by name). Drop the bindings and add a comment
explaining the intent.
2026-05-22 11:26:08 +00:00
emdashbot[bot] 102535f308 ci: release (#1103)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-20 15:40:59 +01:00
Matt Kane 3756168222 feat(registry): profile fields end-to-end (license, authors, security, keywords, repo) (#1112)
* feat(registry): profile fields end-to-end (license, authors, security, 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

* refactor(registry): validate aggregator records at the client-lib trust 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

* refactor(registry-client): validate response envelope via atcute client.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
2026-05-19 22:32:09 +01:00
emdashbot[bot] 361f0e27b4 ci: release (#1096)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-19 18:08:47 +01:00
Matt Kane 67888292c8 fix(plugin-cli): rename --aggregator flag to --registry-url (#1092)
The discovery commands ('search', 'info') used '--aggregator <url>'
while the env var 'EMDASH_REGISTRY_URL', the default URL host
('registry.emdashcms.com'), and the user-facing concept all spoke of
the 'registry'. Three surface words for adjacent concepts hurt the
read-out-loud test.

Rename the flag to '--registry-url' to converge on a single user-facing
term. Internally the helper still calls into an aggregator service
(the registry is the broader system; an aggregator is one indexer of
it) — the rename only affects what users type.

Also fix the README's '--json' surface description, which mirrored the
out-of-date docs claim that 'all commands accept --json'.
2026-05-18 17:48:56 +01:00
Matt Kane 6725e91431 fix(plugin-cli): normalise multi-word flags to kebab-case (#1091)
The 'publish' subcommand uses kebab-case for multi-word flags
('--author-name', '--no-manifest', ...) while 'build', 'dev', and
'bundle' use camelCase ('--outDir', '--validateOnly'). Standardise on
kebab-case across all subcommands.

- --outDir -> --out-dir
- --validateOnly -> --validate-only

The programmatic API options on bundlePlugin / buildPlugin remain
camelCase (they are JS function options, not CLI flags).
2026-05-18 16:02:58 +01:00
Matt Kane c0ce915c55 feat(plugin-cli): sandboxed plugin authoring CLI (#1057)
* feat(registry-cli): extend manifest schema with identity + trust contract

First phase of the sandboxed plugin redesign (#1028b). Adds the
manifest fields that make `src/index.ts` and the in-code descriptor
factory redundant. The trust contract is now hand-authored in the
manifest, where a security reviewer can find it without grep.

New required fields:

- `slug`: ASCII letter then letters/digits/hyphens/underscores, max 64
  chars. Matches the registry lexicon's rkey grammar via the shared
  PLUGIN_SLUG_RE in @emdash-cms/plugin-types.
- `version`: semver 2.0 subset, no build-metadata (atproto rkeys can't
  contain `+`). Validated via PLUGIN_VERSION_RE.
- `publisher`: now required (was optional in #1028a). The runtime
  cannot compute the plugin's AT URI without it; making it optional
  meant the plugin couldn't load locally before first publish.

New optional fields with sensible defaults:

- `capabilities`: array of capability strings. Defaults to []. Each
  entry validated against the current vocabulary; deprecated names are
  hard-rejected with a hint at the replacement (no deprecation window
  for new authoring).
- `allowedHosts`: array of host patterns. Defaults to []. Required
  non-empty when `network:request` is declared without
  `:unrestricted`. Forbidden when `:unrestricted` is declared.
- `storage`: map of collection name -> { indexes, uniqueIndexes? }.
  Defaults to {}.

The cross-field rule for network:request / allowedHosts mirrors the
release-extension lexicon's networkRequestConstraints behaviour, so
authors hit the schema error here rather than a PDS validation error
at publish time.

Schema regenerated. 33 new tests; 204 total passing.

Part of #1028b. The bundle rewrite, init command, plugin migrations,
and `localPlugin` dev helper land in subsequent commits.

* feat(registry-cli): bundle reads identity + trust contract from manifest

Second phase of the sandboxed plugin redesign (#1028b). Bundle no longer
imports src/index.ts for a descriptor factory; the manifest is the
source of truth for identity (slug, version) and the trust contract
(capabilities, allowedHosts, storage). Bundle still probes the runtime
code for the hook/route surface — that's a syntactic property that
needs the code to exist.

Changes to bundle:

- Drop the main-entry build and descriptor extraction. No more
  src/index.ts probing, no more `createPlugin` / default-factory /
  default-object format detection.
- Replace `resolveEntries`: just locates emdash-plugin.jsonc (loaded
  through the same loader the CLI's validate uses) and confirms
  src/plugin.ts exists. No more package.json `exports` parsing.
- Replace `extractResolvedPlugin` with `assembleResolvedPlugin`: builds
  the ResolvedPlugin shape from the manifest, then probes
  src/plugin.ts for hook/route names.
- Probe (renamed from `augmentWithSandboxProbe` to `probePluginSurface`)
  now reads src/plugin.ts. Hard-fails if the default export isn't a
  definePlugin result.
- New error codes: MISSING_MANIFEST, MISSING_PLUGIN_ENTRY,
  MANIFEST_INVALID. Old MISSING_PACKAGE_JSON / MISSING_ENTRYPOINT /
  MAIN_BUILD_FAILED gone.
- Admin entry handling (admin.js, adminPages, adminWidgets) deferred
  to a follow-up issue. The redesign hasn't touched admin yet; that
  surface stays as-is and is gated on the descriptor's `admin` field
  which no longer exists. When admin lands again it'll be a manifest
  field with its own probe.

Changes to translate.ts:

- `NormalisedManifest` gains slug, version, publisher (required),
  capabilities, allowedHosts, storage. Publisher is no longer
  Optional — the schema enforces it.

Fixtures:

- `minimal-plugin/`: src/index.ts gone, sandbox-entry.ts renamed to
  plugin.ts, new emdash-plugin.jsonc with identity + trust contract.
- `bad-plugin/`: stripped to manifest-only (no src/), exercises
  MISSING_PLUGIN_ENTRY. Old "declares hooks but no sandbox entry"
  case isn't possible anymore — there's no descriptor declaring
  anything.

Net diff: -228 lines.

* feat(registry-cli): init command scaffolds a sandboxed plugin

Third phase of the redesign (#1028b). Adds `emdash-registry init [name]`
which produces the three-file plugin layout introduced by the previous
commits: emdash-plugin.jsonc, src/plugin.ts, package.json, plus a
tsconfig, README, .gitignore, and a passing test.

Modes:
- Interactive (default on a TTY): clack prompts for each unset field
  with sensible defaults. ESC / Ctrl+C cancels cleanly.
- `--yes` / `-y` (non-interactive): no prompts; unset fields become
  TODO placeholders in the manifest. The author fixes them before
  first use.
- Non-TTY (CI, pipes): same as `--yes`; prompting into a non-
  interactive stdin would hang.

Pre-fills:
- Publisher: the active session's handle from FileCredentialStore.
  Resolved through @atcute/identity-resolver to a DID before write
  so the runtime never sees a mutable handle. The handle is emitted
  as a `// <handle>` line comment next to the pinned DID for `git
  diff` readability — same convention as the post-publish write-back.
- Author name / email: `git config user.name` / `user.email`.
- Repo: `git remote get-url origin`, normalised from SSH to https
  (`git@github.com:foo/bar.git` → `https://github.com/foo/bar`).
  Falls back to `package.json#repository.url` if no git remote.
- License, description: `package.json` in the target dir if one
  exists (for the "scaffold into existing repo skeleton" case).

Slug defaults to the positional `name`, `basename(--dir)`, or
basename(cwd) in that order. Every flag is optional in every mode.

Exported `resolveHandleToDid` from manifest/publisher.ts so init
can use the same resolver the post-publish write-back does.

Tests: 44 new (template renderers, scaffold filesystem behaviour,
environment probe). 249 total in the package.

* feat(plugins): migrate in-tree sandboxed plugins to the new layout

Fourth phase of the redesign (#1028b). Moves the 5 in-tree sandboxed
plugins to the manifest + src/plugin.ts shape so they become the
canonical references a plugin author looks at.

Each plugin's layout changes from:

  src/index.ts          (descriptor factory, ~50 lines)
  src/sandbox-entry.ts  (runtime code via definePlugin)
  package.json          (main / exports / files / build scripts)

to:

  emdash-plugin.jsonc   (identity + trust contract + admin surface)
  src/plugin.ts         (runtime code, unchanged)
  package.json          (private, typecheck script only)

Plugins migrated:
- atproto
- audit-log
- marketplace-test
- sandboxed-test
- webhook-notifier

Schema gains `admin` (pages + widgets) since four of the five plugins
declare admin surface. Mirrors PluginAdminPage / PluginDashboardWidget
in core. Atproto's plugin.test.ts rewritten to assert against the
manifest instead of the deleted descriptor factory.

KNOWN BREAKAGE: demos that import the old factories
(`auditLogPlugin()`, `webhookNotifierPlugin()`) from
astro.config.mjs are broken until the next commit ships
`@emdash-cms/registry-cli/dev`'s `localPlugin(dir)` helper and
updates the demos.

All published plugins still work — the bundled manifest.json shape
is unchanged. Only authoring changed.

* feat(registry-cli): add localPlugin(dir) dev helper + wire demos

Final piece of the sandboxed-plugin redesign (#1028b). Closes the gap
the plugin migrations opened — demos that previously imported
`auditLogPlugin()` / `webhookNotifierPlugin()` factories now consume
the plugins through their source directories.

New subpath `@emdash-cms/registry-cli/dev` exports `localPlugin(dir)`,
which:

- Reads `<dir>/emdash-plugin.jsonc` via the same loader the CLI uses.
- Confirms `<dir>/src/plugin.ts` exists.
- Resolves the manifest's publisher (handle → DID) so the descriptor
  is in canonical form.
- Returns a PluginDescriptor-shaped object with `entrypoint` set to
  the absolute `file://` URL of `src/plugin.ts`. Vite resolves the
  URL through its standard fs path resolver — no build step needed.

The descriptor carries id, version, capabilities, allowedHosts,
storage, and (when declared) adminPages + adminWidgets from the
manifest. Plugins that don't expose admin surface pass through
without the optional fields, keeping the descriptor tidy.

Demos updated:
- demos/simple: auditLogPlugin() → localPlugin("../../packages/plugins/audit-log")
- demos/plugins-demo: auditLog + webhookNotifier the same way
- demos/cloudflare: webhookNotifier via localPlugin
- infra/cache-demo, infra/blog-demo: same

Trusted plugins (formsPlugin, embedsPlugin, apiTestPlugin) keep their
factory-based imports — they're not on the new shape and aren't part
of this redesign's scope.

Errors surface as a structured LocalPluginError with codes:
- MANIFEST_INVALID
- PLUGIN_ENTRY_MISSING
- PUBLISHER_UNRESOLVED

Tests: 10 new (descriptor shape, error paths, admin pass-through).
259 total in the package.

* feat(plugin-cli): rework sandboxed plugin authoring, build, and CLI

Renames @emdash-cms/registry-cli to @emdash-cms/plugin-cli and the
binary emdash-registry to emdash-plugin. Adds build + dev commands,
consolidates the build pipeline so bundle is a thin packaging step on
top of build. Introduces a strict author-facing SandboxedPlugin type
via the new emdash/plugin type-only subpath; sandboxed plugins now
default-export a bare { hooks?, routes? } object with satisfies
SandboxedPlugin and have no runtime emdash import. Drops definePlugin
and the build shim for sandboxed plugins (definePlugin is native-only
now). Migrates the five in-tree sandboxed plugins to the new shape.
Manifest version is optional and reconciled with package.json#version.

* fix(plugin-cli): adversarial review fixes

- init scaffold emits the new `satisfies SandboxedPlugin` shape and
  npm-shape package.json (build/dev scripts, ./sandbox export, plugin-cli
  devDep) instead of the broken `definePlugin` template
- publish reads package.json#version and reconciles via normaliseManifest
  so the new "version in package.json only" pattern actually publishes;
  malformed package.json surfaces a CliError, not a misleading
  VERSION_MISSING further down
- dev watcher serialises rebuilds (queue collapsed to one follow-up),
  closes the watcher before draining pending on Ctrl-C, short-circuits
  scheduleRebuild during shutdown, handles Windows path separators in
  the outDir ignore glob, clears pending+queuedTrigger in finally so an
  IIFE rejection can't deadlock the session, and removes SIGINT handlers
  on shutdown
- adapter normalises ctx.request to SandboxedRequest shape in-process
  so handlers see the same { url, method, headers: Record } promised by
  the strict type; null/array/non-object default exports rejected with
  a plugin-id-bearing message
- build's readPackageMeta rejects empty/non-string version with the
  same strictness as publish, killing the build-pass/publish-fail
  asymmetry
- pipeline probe rejects invalid hook config (errorPolicy, priority,
  timeout) so untyped JS authors get a build error rather than a
  silently-wrong runtime contract
- versionless minimal-plugin fixture so bundle/publish/build integration
  tests exercise the package.json-as-source-of-truth path
- definePlugin error wording softened for native-plugin authors whose
  id field has a typo
- pipeline error messages and stale comments updated for the no-shim,
  no-definePlugin authoring shape
- removed dead EMDASH_SHIM from the Cloudflare sandbox runner
- changesets retargeted to @emdash-cms/plugin-cli; scaffold/atproto/core
  comments scrubbed for stale registry-cli references

* style: format

* docs(changesets): switch plugin migration examples to diff fences

* style: format

* Fix changeset ordering

* fix(ci): plugin build uses node-direct path; sweep stale registry-cli refs

In-workspace plugins use `node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build`
because pnpm doesn't create the bin shim for a workspace package whose
bin target doesn't exist at install time. Plugin authors outside the
workspace get a published bin with a real dist, so `emdash-plugin build`
works for them via the natural scaffold.

Also fixes stale registry-cli references the rename pass missed:
- .oxfmtrc.json: schema ignore path
- .oxlintrc.json: 7 type-aware-cost allowlist entries
- .github/workflows/ci.yml: build filter includes plugin-cli for test:unit
- package.json: test:unit script
- packages/plugin-types/package.json: description

The schema file is regenerated to match what gen-schema produces. The
previously committed version had been hand-reformatted post-regen and
disagreed with the generator's output.

* fix(ci): remove legacy marketplace bundle path; address review findings

- Delete `packages/marketplace/tests/publish-e2e.test.ts` — invoked the
  legacy `emdash plugin bundle` from core CLI against the new
  manifest-driven plugin layout, which it doesn't understand.
- Remove the validate-plugins CI job — it used the same legacy CLI
  command. Plugin validation is now covered by `pnpm build`, which
  runs the new `emdash-plugin build` probe + manifest checks against
  every in-tree sandboxed plugin.
- Fix `no-base-to-string` lint errors in audit-log/plugin.ts. The
  canonical ContentHookEvent types `event.content.id` as unknown;
  `String(unknown)` lands on '[object Object]' for record IDs. Added
  a small `stringifyId` helper that returns '' for non-string/number
  inputs so the caller's existence check skips bad rows.
- pipeline.ts now hard-errors when the probed module has no `default`
  export, instead of silently falling through to an empty plugin
  (build had been writing dist/ artifacts with empty hooks/routes for
  any source that used `export const plugin = ...`).
- Scaffold README camelCases hyphenated slugs for the import binding.
  Slugs like `my-plugin` were producing `import my-plugin from ...`
  which is a syntax error. Test added with a hyphenated fixture.

Both bot review comments addressed.

* style: format

* fix(plugin-cli): bump test timeout to 30s for bundle tests on slow CI

bundle.test.ts > 'produces a tarball + manifest for a minimal valid
plugin' timed out at the 5s default on the GitHub-hosted runner.
The test runs the full build pipeline (tsdown probe + transpile +
tarball pack), which is fast locally (<2s) but cold-starts at 5-8s
on CI. Bump to 30s globally for the plugin-cli vitest config.

* chore: update lockfile

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ascorbic <213306+ascorbic@users.noreply.github.com>
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
2026-05-18 15:01:00 +01:00