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.
* ci: release
* format
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Kane <m@mk.gg>
* ci: release
* format
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Kane <m@mk.gg>
#1489 migrated only getSiteSettings and ensureSearchHealthy off the
never-settling-promise pattern. Four more caches still cached an in-flight
promise on a long-lived singleton, so a request cancelled mid-fetch left a
promise that never settled and wedged every later request on the isolate
(524 at the 100s wall). Fix all four and rename the helper to be platform
neutral:
- Rename IsolateCache/isolateCachedAsync to SingleFlightCache/singleFlightCached
(internal util in core, which also runs on Node; not Cloudflare-specific).
- byline field-defs cache: cache the value behind a reclaimable lock, preserving
the cross-isolate persisted-version gate.
- config/secrets resolveSecretsCached: per-db SingleFlightCache.
- x402 enforcer: cache the ready server, drop the shared _initPromise.
- atproto plugin: inline poison-immune lock-and-poll for token refresh (KV is
the source of truth; waiters never await the owner's promise).
Adds regression tests reproducing the stranded-owner hang for byline and atproto.
Follow-up to #1489.
* fix(forms): render public form embeds via SSR plugin routes
* Address public form SSR review feedback
* style: format
---------
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
* 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.
* 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>
* fix(deps): catalog-pin zod so trusted plugins typecheck
Astro bundles its own Zod and re-exports it as 'astro/zod'. Trusted
plugins like @emdash-cms/plugin-forms import their route schemas via
'astro/zod', then pass those schemas to definePlugin() in core. With
emdash's 'zod: ^4.3.5' resolving independently of Astro's caret,
pnpm kept two Zod 4 patches in the tree (e.g. 4.3.6 alongside 4.4.1).
Zod 4 embeds its semver in the type system, so two patches of Zod 4
are not assignable to each other. The forms plugin's route schemas
(ZodObject<..., $strip>) were rejected by PluginRoute<TInput>['input']
(ZodType<unknown, unknown, $ZodTypeInternals<unknown, unknown>>) with
'Type "3" is not assignable to type "4"' on the internal version
field. The native definePlugin overload silently failed, TS fell
through to the StandardPluginDefinition overload, and reported a
misleading 'id does not exist' error -- masking 8 cascading errors.
Catalog-pinning Zod forces a single workspace-wide instance and
restores normal overload resolution. No code changes needed in core
or plugins/forms.
Also adds a pnpm-workspace.yaml comment explaining the gotcha so the
next person doesn't bump emdash's pin past Astro's range.
* feat(registry): experimental decentralized plugin registry
Adds opt-in support for installing sandboxed plugins from the
decentralized plugin registry described in RFC #694. Enabled via
`experimental.registry.aggregatorUrl` in the EmDash integration
options; when set, the admin UI replaces marketplace browse/install
with the registry path.
Server: new install handler (RFC verification chain), endpoint at
POST /_emdash/api/admin/plugins/registry/install, migration 038 adds
`source = 'registry'` plus `registry_publisher_did` /
`registry_slug` columns on `_plugin_state`, runtime sync split into
shared marketplace + registry tiers via a normalized opaque
`r_<hash>` plugin id.
Browser: aggregator XRPC calls go direct from the admin UI via
@emdash-cms/registry-client. Install POST runs through the server.
Includes a minimum-release-age policy with a per-publisher exclude
allowlist, enforced both client-side (UX) and server-side (gate).
Hardening (5 rounds of adversarial review): bundle id rewritten to
the derived pluginId before storage, aggregator identity
cross-checked, artifact and aggregator URLs validated for SSRF
(https-only in prod, IPv6 brackets handled), per-request and total
budgets on every outbound call, decompressed bundle capped at 256
KiB to match the RFC publish-time limit, migration 038 idempotent
on both SQLite and Postgres.
Known gaps tracked for follow-up: full MST signature verification
against the publisher's PDS, multibase multihash decoding (hex SHA-256
is accepted today), registry plugin update + uninstall handlers.
* fix(registry-lexicons): drop codegen from build script
The generated lexicon types are committed to git so consumers don't
need the codegen toolchain. Running lex-cli generate as part of the
default build pipeline broke Cloudflare Pages builds for sites that
pull registry-lexicons in transitively, because lex-cli imports
lex.config.ts directly and Node in the CF Pages build environment
can't load .ts natively.
Codegen moves to a separate `regen` script (`pnpm regen` runs
codegen + full build). Maintainers run it when they edit the
lexicons; consumers just consume the committed output.
* fix(registry): copilot review fixes
- Drift check normalizes capabilities (filter strings, dedupe, sort) on
both browser and server so reorderings or junk entries can't trigger
spurious rejection. Adds a shared normalizeCapabilities helper in
registry/config.ts and a mirror in admin/lib/api/registry.ts.
- RegistryPluginDetail no longer trusts the aggregator-supplied
ext?.capabilities as already-validated string[]; runs it through
normalizeCapabilities before display and before send.
- Fix stale '32 MiB' docstring on extractBundle (cap is actually
MAX_DECOMPRESSED_BUNDLE_BYTES = 256 KiB).
- Fix plugin-id.ts JSDoc: validatePluginIdentifier regex is
/^[a-z][a-z0-9_-]*$/ (allows hyphens); the prior 'cannot collide
with marketplace ids' claim was too strong and is now framed as
'syntactically distinct, plus an explicit pre-existing-row check
in the install handler.'
* fix(registry): address review findings + CI failures
CI fixes:
- Rename normalizeCapabilities -> canonicalCapabilitiesForDriftCheck
to avoid namespace clash with the existing capability normalizer
exported from @emdash-cms/plugin-types via core's index. The old
name shadowed plugin-types' helper at the top level of core's dist,
which made the definePlugin() overload set look ambiguous to TS in
plugins/forms and caused a typecheck cascade there.
- [...seen].toSorted() instead of [...seen].sort() to clear the
e18e/prefer-spread-syntax + unicorn/no-array-sort lint errors.
Review findings (ask-bonk[bot]):
- HIGH: drift check tripped on every install when the release record's
extension was empty. The browser now omits acknowledgedDeclaredAccess
when capabilities is empty, opting out of the server-side drift gate
for the (currently common) case where publishers haven't filled in
the extension block. The bundle's real capabilities are still bound
to the checksum-verified bytes.
- HIGH: DID-only publishers (no resolvable handle) could be linked from
the browse grid but never installed because the server rejects
handles without a '.'. Cards now render as non-interactive with a
'Publisher handle unresolved' badge; the detail page surfaces a
matching warning and disables Install.
- MEDIUM: registry-enabled sites were unconditionally routing existing
marketplace plugin detail URLs to RegistryPluginDetail, breaking deep
links. Detail-route selection now discriminates by param shape
(pluginId.includes('/')) rather than the manifest flag.
- MEDIUM: state-row write failure after storeBundleInR2 left orphan
bundles. Best-effort cleanup in the catch via deleteBundleFromR2.
- LOW: parseDurationSeconds runs on the user-supplied integration
option per install (not the already-normalized manifest shape). Wrap
in try/catch and surface as REGISTRY_POLICY_INVALID rather than
letting it bubble to a generic INSTALL_FAILED.
- LOW: validator-pattern doc drift in plugin-id.ts (already fixed in
the prior commit).
* fix(registry): move registry config types to their own module
The new RegistryConfig + ExperimentalConfig interfaces lived alongside
definePlugin's overloads in astro/integration/runtime.ts. tsdown +
rolldown's chunking decided to inline a bigger subset of plugin-related
types into the entry chunk as a result, which broke definePlugin()
overload resolution for trusted plugins building against core's dist
on CI (plugins/forms failed with 'id does not exist in type
StandardPluginDefinition').
Move both types to packages/core/src/registry/types.ts (still re-exported
from runtime.ts for backwards compatibility) so the chunking matches
main's layout and definePlugin's overloads resolve as before.
* fix(registry): wire up real-world install + display polish
Aggregator (apps/aggregator):
- Add CORS to /xrpc/* so the admin UI can call it from any origin
(preflight 204, response headers on every method). Aggregator is a
public read-only service; * is correct here.
Core (packages/core):
- Implement multibase-multihash checksum verification by re-encoding
our SHA-256 digest in the same 'b<base32>' shape the registry CLI
produces, rather than decoding the publisher's checksum. Same trust
contract, no base32 decoder needed. Bare hex SHA-256 still accepted
as a convenience fallback.
- Switch install handler to take 'did' (not handle) so packages whose
handle the aggregator couldn't resolve are still installable. The
browser resolves handle→DID via the aggregator before posting and
sends DID directly; the server skips resolvePackage and goes
straight to getPackage.
- Coerce 'experimental.registry' bare-string shorthand into the full
RegistryConfig object via 'coerceRegistryConfig'. 'registry:
"..."' is now equivalent to 'registry: { aggregatorUrl: "..." }'.
- Plumb 'experimental' through the integration's serializableConfig
so the manifest endpoint actually sees the user's registry block.
Previously it was being stripped, so the admin UI never branched to
the registry path.
- Split RegistryConfig + ExperimentalConfig types into their own
module (registry/types.ts) so they don't get bundled into the
astro/integration/runtime.ts dist chunk -- the wider inlining was
breaking definePlugin overload resolution for trusted plugins
building against core's dist.
Admin (packages/admin):
- New <PublisherHandle> component + usePublisherHandle hook with
tri-state result ('ok' / 'invalid' / 'missing'). Renders @handle,
'Unverified publisher' (red), or DID respectively. Uses
@atcute/identity-resolver's LocalActorResolver for bidirectional
handle verification, localStorage-cached for 24h.
- Detail page disables install on 'invalid' status (publisher claims
a handle that doesn't round-trip back to its DID -- impersonation
risk). Surfaces 'We couldn't verify this publisher's identity'
alert in plain language.
- Detail page reads installed state from fetchPlugins() and swaps
the Install button to 'Installed' (disabled) when the package
already has a 'source = "registry"' row matching its DID + slug.
React Query's existing ['plugins'] invalidation handles the
post-install UI update.
- Browse cards reuse <PublisherHandle> (variant='card') and link by
handle when available, DID otherwise. Detail page parses either
form from the URL.
- Browser sends 'did' (not handle) in the install POST.
Workspace:
- '@cloudflare/kumo' moved to the pnpm catalog and bumped to ^1.16.0
workspace-wide. Older 1.10.0 was missing Sidebar export and being
hoisted into the admin via packages/blocks's transitive dep.
- Add '@atcute/multibase' to core (for checksum encoding) and
'@atcute/identity-resolver' to admin (for DID->handle resolution).
- Update DEFAULT_AGGREGATOR_URL + DiscoveryClient doc example from
'experimental-registry.emdashcms.com' to 'registry.emdashcms.com'
(the actual production host).
* fix(registry): adversarial review round 6 findings
Addresses 7 findings from the round-6 adversarial review and
documents the eighth.
#1 (high) Capability consent bypass [registry.ts, RegistryPluginDetail.tsx]
The drift check was gated on the client sending acknowledgedDeclaredAccess.
If the publisher's release record had no extension, the admin saw an
empty permission dialog, omitted the acknowledgement, and the server
skipped the check entirely -- letting a bundle whose manifest declares
real capabilities slip through behind an empty consent UI. Server now
extracts capabilities from the bundle manifest after download and
refuses with DECLARED_ACCESS_REQUIRED if the bundle declares any
capabilities and no acknowledgement was sent. Client always sends the
list (empty when no extension) so the new server check is always armed.
#2 (high) Concurrent install bundle deletion [registry.ts]
Two parallel installs of the same (did, slug, version) both passed the
pre-existing-row check, both uploaded to the same deterministic R2
prefix, and one then won the state-row PK race. The loser's catch block
deleted the R2 bundle the winner had just written. On state-write
failure we now re-query the state row: if a winner exists, we lost the
race and must not touch the R2 bundle. Cleanup runs only when the
failure is a real DB error, not a lost concurrent install.
#3 (high) SSRF via DNS-resolving public hostnames [registry.ts, ssrf.ts moved]
Literal-IP blocklist alone left a DNS-rebinding gap: any public DNS
service resolving an attacker-chosen hostname to loopback / RFC1918 /
169.254.169.254 passed the URL check. The import pipeline already
shipped resolveAndValidateExternalUrl which does Cloudflare DoH
resolution and rejects on any forbidden resolved address; reuse it
for artifact downloads. Move src/import/ssrf.ts to src/security/ssrf.ts
to reflect that it's not import-specific. Leave a re-export shim at
the old path so 13 existing callers keep working unchanged. Add
#security/* path alias.
#5 (high) Aggregator-supplied handles treated as verified [PublisherHandle.tsx]
usePublisherHandle returned status: 'ok' with the aggregator-supplied
handle whenever one was present, skipping local DID->handle round-trip.
A compromised aggregator could label an attacker DID as e.g.
'stripe.com' and the UI would render it as verified. Always run
LocalActorResolver via resolveDidToHandle; use the aggregator handle
only for a cross-check. If the aggregator's claim differs from the
verified handle, mark the publisher invalid.
#6 (medium) Postgres migration 038 schema-qualification [038_registry_plugin_state.ts]
The columns probe queried information_schema.columns without filtering
by table_schema. A _plugin_state table in another schema (multi-tenant
Postgres, per-test schemas) could make the migration skip the column
adds. Filter by table_schema = current_schema().
#7 (medium) Install errors leak full artifact URLs [registry.ts]
fetchArtifact recorded each full URL in the joined error message that
bubbled up to the admin client. Artifacts hosted on storage backends
often carry presigned tokens in the query string; failed installs were
leaking those into HTTP responses and logs. Strip query and fragment
when building client-visible errors (origin + path only); log the full
URL server-side for debugging.
#8 (medium) Credentialed aggregator URLs accepted [config.ts]
validateAggregatorUrl accepted https://user:pass@example.com.
The normalized URL ends up in the admin manifest and is shipped to
every admin browser; browser fetch() also rejects credentialed URLs
outright. Reject them at config-validation time.
#4 (high, documented not fixed) Aggregator-trust-root scope [types.ts]
Full MST proof / publisher signature verification is not in this PR;
the server still trusts the aggregator-supplied (did, slug, checksum,
artifact URL). Expand the JSDoc on EmDashConfig.experimental.registry
to spell out exactly what the v1 trust contract is, what EmDash does
verify independently (checksum, manifest id/version/capabilities), and
what it doesn't (release-record signatures, replay). Recommendation:
point aggregatorUrl only at an aggregator you operate or trust at
centralized-source level until signature verification lands.
* fix(registry): adversarial review round 7 followups
Two LOW findings from the round-7 review (PR #1011 comment).
NSID exact-match in RegistryPluginDetail.tsx
Round-6 left a startsWith() match on the release-extension key.
RFC 0001 fixes the NSID for the release extension; accepting prefix
variants (...releaseExtensionV2, ...releaseExtension.deprecated)
would let a publisher render a different capability list than the
canonical key would. Use exact-equality keyed lookup.
Registry plugin uninstall affordance in PluginManager.tsx
Registry-installed plugins appear in PluginManager but the Uninstall
button is gated on isMarketplace. Admins see a permanent-looking
install with no way to remove it short of editing the DB and R2 by
hand. Add an inline note for source === 'registry' rows that says
uninstall isn't available yet and points the admin at the disable
toggle. Full uninstall handler lands in a follow-up PR.
* feat(plugin-marketplace-test): add default export for registry-cli bundler
The registry-cli bundler resolves the plugin descriptor by looking for a
default export (function or pre-resolved descriptor) or a named
`createPlugin` export. The named-only `marketplaceTestPlugin` export wasn't
discoverable, so `emdash-registry bundle --dir packages/plugins/marketplace-test`
failed with INVALID_PLUGIN_FORMAT.
Add `export default marketplaceTestPlugin` to match the convention used by
other plugins (e.g. `forms`, `color`).
* feat(aggregator): scaffold plugin-registry aggregator (Slice 1 PR 1a)
Lands the apps/aggregator project skeleton plus the shared
@emdash-cms/atproto-test-utils package skeleton. No business logic yet —
this PR establishes the structure so subsequent PRs each touch one
component.
apps/aggregator
- Wrangler config: D1, Records Queue (+ DLQ), Records DO, 6h reconciliation
Cron, vars (JETSTREAM_URL, CONSTELLATION_URL, WANTED_COLLECTIONS).
- Cloudflare Vite plugin for dev/build (`vite dev` / `vite build`),
`wrangler deploy` after build for ship.
- Initial D1 migration `0001_init.sql` lands every v1 table at once
(packages, releases + FK + idx, release_duplicate_attempts,
mirrored_artifacts, labels, label_state + partial enforce idx,
labellers, packages_fts + triggers, ingest_state, known_publishers).
Slices 2 and 3 read these tables but don't add new ones, so this is
the only DDL we expect to ship before NSID stabilisation.
- Worker entrypoint exports RecordsJetstreamDO + a no-op default with
fetch/queue/scheduled stubs; subsequent PRs fill them in.
- Test rig uses @cloudflare/vitest-pool-workers v0.16's cloudflareTest()
plugin. Migrations are read at config time and piped into the worker
isolate via the TEST_MIGRATIONS binding; tests apply them in beforeAll.
Smoke test proves migrations apply, INSERT round-trips, FTS5 trigger
fires, and the FK rejects orphan releases.
packages/atproto-test-utils (skeleton)
- Private workspace package consumed by both registry-cli (later) and
apps/aggregator. PR 1b lands the real-crypto MockPds + MockJetstream
+ MockDidResolver + createFakePublisher helper.
Workspace
- apps/* added to pnpm-workspace.yaml.
- Catalog entries added for @atcute/{car,cbor,cid,crypto,firehose,
jetstream,mst,repo,xrpc-server,xrpc-server-cloudflare},
@cloudflare/{vite-plugin,vitest-pool-workers}, vite.
- vitest bumped to ^4.1.5 — required by vitest-pool-workers v0.16.
* feat(atproto-test-utils): real-crypto MockPds + mocks (Slice 1 PR 1b)
Lands the in-memory atproto fixtures so the aggregator's verification path
exercises the same code in tests as in production.
The load-bearing claim: a record signed by a FakeRepo, fetched via
MockPds.handle as a sync.getRecord CAR, and fed into @atcute/repo's
verifyRecord round-trips cleanly. Mocks that skip signing would let
verification regressions slip through; this rules that out.
Components:
- FakeRepo wraps @atproto/repo's Repo + MemoryBlockstore for one DID. Real
P-256 keypair, real signed commits, real MST construction. getRecordCar
uses @atproto/repo's getRecords provider (same path the cirrus PDS
reference uses).
- MockPds is multi-tenant (mounts many FakeRepos), implements
FetchHandlerObject for @atcute/client, and serves both publish-side
endpoints (applyWrites, putRecord, repo.getRecord) and aggregator-side
endpoints (sync.getRecord-as-CAR with application/vnd.ipld.car,
listRecords). Response shapes mirror cirrus.
- MockJetstream is a driveable async iterable; tests emit commit events,
subscribers receive filtered events, history replays on reconnect with
cursor.
- MockDidResolver maps DIDs to DID documents with the publisher's signing
multikey + PDS endpoint.
- createFakePublisherFixture wires all four together: createPublisher
registers a new keypair-backed repo with the PDS and resolver in one call.
Tests (11): record round-trips for profile + release records, signature
mismatch rejection, exclusion-proof CAR for missing records, JSON
listRecords shape, Jetstream filtering + history replay, DID resolution
with PDS endpoint extraction.
Workspace: adds @atproto/repo + @atproto/crypto to catalog as test-only
deps. Production aggregator still uses @atcute/repo for verification — the
heavyweight @atproto package is private to the test fixture package.
* chore: align @vitest/browser-playwright + @vitest/ui with vitest 4.1.5
PR 1a bumped vitest in the catalog from 4.0.18 to 4.1.5 because
`@cloudflare/vitest-pool-workers@0.16` requires it. The two related
packages had separate version pins outside the catalog and stayed at
4.0.x, producing "Running mixed versions is not supported" warnings
on every admin/core test run.
Bump both to 4.1.5 to match.
The bump to @vitest/browser-playwright 4.1.5 also tightens playwright's
strict-mode role inference: <input type="file" aria-label="Upload file">
is now treated as having an accessible name that matches a `/Upload/`
regex, which collides with the actual Upload button in
MediaPickerModal. Anchor the test's regex (`/^Upload$/`) so it only
matches the button's exact accessible name.
* fix(aggregator): address PR feedback (lint, wrangler peer, copilot)
- Bump catalog wrangler from ^4.80.0 to ^4.83.0. @cloudflare/vite-plugin
(pulled in transitively via @astrojs/cloudflare) requires this; the
outdated catalog version broke demos/cloudflare typecheck and the
Cloudflare-template smoke tests on CI. infra/cache-demo had already
pinned ^4.83.0, so this brings the catalog in line with the highest
in-tree pin.
- Drop redundant `| undefined` from FakeRepo.getRecordValue's return type;
`unknown` already includes undefined (typescript-eslint:no-redundant-type-constituents).
- Replace deep import `@atproto/repo/dist/sync/provider.js` with the
package's public `getRecords` export. Same function, no exports-map
brittleness on upgrade. (Per Copilot review.)
- Validate did:plc:/did:web: prefix in MockPds parseDid instead of
accepting any did:* and casting. Matches the AtprotoDid type's runtime
guarantee — invalid methods (e.g. did🔑) now reject at the boundary
instead of slipping through tests. (Per Copilot review.)
- Drop rootDir from apps/aggregator and packages/atproto-test-utils
tsconfigs; both `include` test directories that sit outside `./src`,
which trips TS6059 in some tooling. Without rootDir, TS infers from
include and tests live cleanly alongside source. (Per Copilot review.)
* fix(atproto-test-utils): address adversarial review findings
Six bugs/gaps found in adversarial review of the mock infrastructure.
Each fix lands with a regression test so the same bug can't slip back.
1. MockJetstream cursor was off-by-one. The replay filter used
`event.time_us < cursor`, which redelivered the cursor event itself.
Real Jetstream treats the cursor as "last seen, don't redeliver" —
replay must be strict-after. Fixed to `<=` and added a test that emits
two events, subscribes with cursor = first event's time_us, and
asserts only the second is delivered.
2. MockJetstreamSubscription.cursor returned the global last event time,
not the per-subscriber position. A subscriber that had consumed only
event 3 of 10 read sub.cursor → time of event 10; reconnecting with
that value would silently lose events 4-9. Now tracks
lastDeliveredTimeUs per subscriber and exposes it through the cursor
getter. Added a test that emits two events, consumes one, and asserts
sub.cursor reflects the consumed event.
3. MockJetstreamSubscription.next() now throws when called concurrently
from two consumers (previous behaviour silently orphaned the first
resolver). Concurrent next() is legal for AsyncIterator; this mock
doesn't support it, so failing loudly beats hanging.
4. MockPds dispatched on URL pathname only, ignoring HTTP method —
`GET applyWrites` returned 200. Now switches on `(method, pathname)`
and returns 405 MethodNotAllowed for known endpoints used with the
wrong verb. Added a test asserting GET on applyWrites returns 405.
5. MockPds.repoApplyWrites only handled #create. PR 3's tests will need
update + delete flows to model profile updates and tombstoning. Added
FakeRepo.updateRecord + deleteRecord that drive @atproto/repo's
applyWrites with the right WriteOpAction, and dispatched on $type in
MockPds. Added a round-trip test that publishes a profile, updates
its license, then deletes it.
6. FakePublisher.publishProfile defaulted authors to [] and let security
end up empty if neither securityEmail nor securityUrl was passed —
both fields require minLength: 1 in the lexicon. Now throws when no
security contact is provided, and defaults authors to a single entry
derived from the publisher's handle. Added two tests: one asserts the
throw, the other asserts the default authors entry uses the handle.
Also reverted a misguided `cursor: undefined` addition to listRecords —
JSON.stringify drops undefined keys, which IS the cirrus end-of-stream
shape (the `cursor` field is optional). The reviewer's concern there was
misread; updated the comment to spell out the contract.
* fix(aggregator): drop placeholder database_id so D1 auto-provisions
A literal placeholder ID makes wrangler think the binding is already
configured and skip provisioning on first deploy, then fail when trying
to use a non-existent database. Omit the field entirely so auto-provision
fires.
* fix(aggregator): use wrangler-generated Env instead of hand-rolled type
Per the project's CLAUDE.md and wrangler's own guidance, the Env type
should come from `worker-configuration.d.ts` (generated by
`wrangler types`) rather than being maintained by hand. The hand-rolled
shape would drift from the actual bindings any time wrangler.jsonc
changed.
- Add `worker-configuration.d.ts` (generated, committed alongside the
similar files in packages/marketplace, infra/perf-monitor, the
cloudflare templates, etc.).
- Drop the manual `Env` interface from src/env.ts; keep only the
project-specific `RecordsJob` type.
- Drop `@cloudflare/workers-types` from devDependencies + tsconfig
`types` array. Wrangler now ships the runtime types in the generated
d.ts; the workers-types package is superseded.
- Drop `test/env.d.ts` — its single triple-slash for vitest-pool-workers
is now covered by the tsconfig's `types` entry.
- Update records-do.ts and index.ts to consume the global `Env`.
Re-run `wrangler types` after editing wrangler.jsonc to keep the
generated file in sync.
* chore(aggregator): WANTED_COLLECTIONS as code constant + drop slice/PR refs
WANTED_COLLECTIONS is part of the protocol contract, not a per-deployment
tunable. Move to apps/aggregator/src/constants.ts where the type system
can keep it honest, and drop it from wrangler.jsonc `vars`. Production,
staging, dev, and self-hosted instances all subscribe to the same NSIDs.
Also strip slice/PR scaffolding language from comments — those refs
mean nothing to anyone reading the code outside the immediate dev
context. Replace with descriptions of what the placeholder will become
(e.g. "PDS-verified ingest will land here") rather than which sub-PR
will land it.
Regenerated worker-configuration.d.ts after the wrangler.jsonc change.
* fix(plugins): rename deprecated capabilities and read version from package.json
Renames the deprecated capability aliases (`read:content`, `write:content`,
`read:media`, `write:media`, `network:fetch`, `network:fetch:any`) used by the
built-in plugins to their current names. Plugin descriptors now read `version`
from each package's `package.json` instead of carrying a stale hard-coded
literal.
* fix(marketplace): accept current capability names in publish manifest validation
The publish endpoint's allow-list still only contained the deprecated capability
aliases, so manifests using the current names (e.g. `content:read`) were
rejected with 400. Adds the current names alongside the deprecated ones during
the transition window. The publish e2e test now reads the audit-log version
from `package.json` instead of hard-coding it.
Composable field widgets for `json` fields. Four widgets configured
entirely through seed `options` — no React required from site builders:
- object-form — inline form for flat JSON objects
- list — ordered array editor with add/remove/reorder
- grid — rows × columns matrix (toggle / text / number / select cells)
- tags — free-form tag/chip input for string arrays
Widgets use Kumo components and semantic design tokens to match the
admin's visual language. Stored data is clean JSON that survives
removing the plugin — no shape mutation, no new columns, no migration.
Ships with 30 unit tests covering render, onChange shapes, grid
legacy-array format normalization, and tags dedupe/max/transform.
Also widens `FieldDescriptor.options` from
`Array<{ value: string; label: string }>` to
`Array<{ value: string; label: string }> | Record<string, unknown>`
so plugin widgets can accept arbitrary widget config (not only enum
choices). The array shape for `select` / `multiSelect` continues to
work unchanged — `ContentEditor` narrows at the usage site.
Signed-off-by: Filip Ilic <ilic.filip@gmail.com>
* ci: release
* chore: restore original PR/author attribution in CHANGELOGs
* style: format
---------
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Kane <m@mk.gg>
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
* revert: undo accidental 1.0.0 release; prevent recurrence
Reverts 46f3acb (ci: release #759), which bumped all 8 publishable
packages to 1.0.0. Restores 0.7.x versions and pending changesets so
the next release lands at 0.8.0.
Root cause: peer-dep cycle between `emdash` and `@emdash-cms/auth-atproto`,
both using `workspace:*`. Changesets resolves `workspace:*` to the exact
current version for semver checks, so a minor bump on either side falls
outside the range and escalates the cycle's other side to major. Other
plugins use `workspace:>=X.Y.Z` and don't trip this — switching both
ends of the cycle to the same form fixes the escalation.
The existing workflow guard greps `pnpm changeset status` for "bumped
at major", which only runs at PR-creation time. Once the version PR is
merged, no changesets are pending and the grep returns nothing — so
the publish step proceeded with already-bumped 1.0.0 versions.
Replaces it with a package.json scan that fails on any non-private 1.x
version. Wired into both `release.yml` (publish-time) and `ci.yml`
(every PR, including the auto-generated Version Packages PR).
Also adds .claude/* to .gitignore (mirroring the .opencode pattern)
so local agent state doesn't accidentally land in commits.
* style: format
* chore: update lockfile for workspace range changes
---------
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>