chore: import upstream snapshot with attribution
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,19 @@
|
||||
# @emdash-cms/plugin-types
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#1719](https://github.com/emdash-cms/emdash/pull/1719) [`7c5de08`](https://github.com/emdash-cms/emdash/commit/7c5de08f6370ea88500b7ec425d58b2c82443260) Thanks [@swissky](https://github.com/swissky)! - Adds a `taxonomies:read` plugin capability with read-only taxonomy access: plugins that declare it get `ctx.taxonomies` to list taxonomy definitions (`getAll()`), fetch the terms of a taxonomy (`getTerms()`), and read the terms assigned to a content entry (`getEntryTerms()`) — in-process and in both sandbox runners.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#1461](https://github.com/emdash-cms/emdash/pull/1461) [`b01aa9b`](https://github.com/emdash-cms/emdash/commit/b01aa9bbb436bcec07516b499eb0516cfbe414b4) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes registry installs failing with "Plugin manifest has changed since you consented" for plugins that declare hook-registration capabilities (email transport, email events, page fragments) or read user records. Plugin bundles now declare their access as a structured `declaredAccess` contract that the registry record, the install-consent dialog, and the sandbox all read consistently, so every capability a plugin declares is shown for consent and enforced — no capability is silently dropped. Re-publish affected plugins to adopt the new bundle format; existing installs are unaffected.
|
||||
|
||||
## 0.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#923](https://github.com/emdash-cms/emdash/pull/923) [`943df46`](https://github.com/emdash-cms/emdash/commit/943df46d62043df386eef4664fbba4710be16c31) Thanks [@ascorbic](https://github.com/ascorbic)! - Adds `@emdash-cms/plugin-types`: shared TypeScript types for the EmDash plugin manifest contract — capability vocabulary (`PluginCapability`, `CAPABILITY_RENAMES`, `isDeprecatedCapability`, `normalizeCapability`), manifest shape (`PluginManifest`, `ManifestHookEntry`, `ManifestRouteEntry`, `PluginAdminConfig`, `PluginStorageConfig`). Consumed by both `emdash` (manifest reader at install/runtime) and `@emdash-cms/registry-cli` (manifest writer at bundle/publish time). After the registry phase 1 cutover removes the legacy bundling code from core, both sides will continue depending on this single source of truth.
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@emdash-cms/plugin-types",
|
||||
"version": "0.2.0",
|
||||
"description": "Shared TypeScript types for the EmDash plugin manifest contract: capability vocabulary, manifest shape, hook/route entry types. Consumed by core (manifest reader at install/runtime) and plugin-cli (manifest writer at bundle/publish time).",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"prepublishOnly": "node --run build",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run",
|
||||
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arethetypeswrong/cli": "catalog:",
|
||||
"publint": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"keywords": [
|
||||
"emdash",
|
||||
"cms",
|
||||
"plugin",
|
||||
"manifest",
|
||||
"types"
|
||||
],
|
||||
"author": "Matt Kane",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/emdash-cms/emdash.git",
|
||||
"directory": "packages/plugin-types"
|
||||
},
|
||||
"homepage": "https://github.com/emdash-cms/emdash"
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* @emdash-cms/plugin-types
|
||||
*
|
||||
* Shared TypeScript types for the EmDash plugin manifest contract.
|
||||
*
|
||||
* Two packages need to agree on this shape:
|
||||
*
|
||||
* - **`emdash` (core)** reads `manifest.json` at install time and again at
|
||||
* runtime when gating a sandboxed plugin's access to capabilities. Core
|
||||
* is the contract reader.
|
||||
* - **`@emdash-cms/plugin-cli`** writes `manifest.json` during bundling
|
||||
* (extracted from the plugin author's source) and publishes the resulting
|
||||
* records via atproto. plugin-cli is the contract writer.
|
||||
*
|
||||
* Anything that has to round-trip cleanly between writer and reader belongs
|
||||
* here: the capability vocabulary, the manifest shape, the hook/route entry
|
||||
* types, and the legacy-name rename map.
|
||||
*
|
||||
* Things that don't belong here:
|
||||
*
|
||||
* - `ResolvedPlugin` and the rest of core's runtime plugin types — those
|
||||
* are core-internal shapes for in-memory plugin instances and pull in
|
||||
* a lot of Astro / blocks / schema dependencies.
|
||||
* - The `@atcute/*` lexicon types for the registry's atproto records.
|
||||
* Those live in `@emdash-cms/registry-lexicons` since they describe a
|
||||
* different contract layer.
|
||||
*
|
||||
* EXPERIMENTAL: this package is published as part of the experimental plugin
|
||||
* registry roll-out. Pin to an exact version while RFC 0001 is in flight;
|
||||
* the manifest shape may evolve before the registry phase 1 cutover.
|
||||
*/
|
||||
|
||||
// ── Plugin capability vocabulary ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The full set of capability strings the bundler and the runtime understand,
|
||||
* including the deprecated legacy aliases. New plugins should use only the
|
||||
* canonical names; the legacy ones are kept in the union so old code
|
||||
* typechecks during the deprecation window.
|
||||
*
|
||||
* The runtime alias layer (`normalizeCapability`) maps legacy names to
|
||||
* canonical ones at every external boundary.
|
||||
*/
|
||||
export type PluginCapability =
|
||||
// Network
|
||||
| "network:request" // ctx.http (host-restricted via allowedHosts)
|
||||
| "network:request:unrestricted" // ctx.http (unrestricted)
|
||||
// Content
|
||||
| "content:read"
|
||||
| "content:write"
|
||||
// Taxonomies (read-only; there is no plugin-facing taxonomy write API)
|
||||
| "taxonomies:read"
|
||||
// Media
|
||||
| "media:read"
|
||||
| "media:write"
|
||||
// Users
|
||||
| "users:read"
|
||||
// Email
|
||||
| "email:send"
|
||||
// Hook registration
|
||||
| "hooks.email-transport:register" // exclusive `email:deliver` (transport)
|
||||
| "hooks.email-events:register" // `email:beforeSend` / `email:afterSend`
|
||||
| "hooks.page-fragments:register" // `page:fragments` (script/style injection)
|
||||
// Deprecated aliases (kept for the deprecation window; warnings emitted at
|
||||
// bundle time, hard fail at publish time).
|
||||
| "network:fetch"
|
||||
| "network:fetch:any"
|
||||
| "read:content"
|
||||
| "write:content"
|
||||
| "read:media"
|
||||
| "write:media"
|
||||
| "read:users"
|
||||
| "email:provide"
|
||||
| "email:intercept"
|
||||
| "page:inject";
|
||||
|
||||
/**
|
||||
* Deprecated capability names that map to current names.
|
||||
*
|
||||
* Accepted at every external boundary (manifest parse, definePlugin, sandbox
|
||||
* adapter) and silently normalized to the new names before reaching the
|
||||
* runtime. Authors are warned at `bundle` / `validate`, hard-failed at
|
||||
* `publish`.
|
||||
*/
|
||||
export type DeprecatedPluginCapability =
|
||||
| "network:fetch"
|
||||
| "network:fetch:any"
|
||||
| "read:content"
|
||||
| "write:content"
|
||||
| "read:media"
|
||||
| "write:media"
|
||||
| "read:users"
|
||||
| "email:provide"
|
||||
| "email:intercept"
|
||||
| "page:inject";
|
||||
|
||||
/** Current (non-deprecated) capability names. */
|
||||
export type CurrentPluginCapability = Exclude<PluginCapability, DeprecatedPluginCapability>;
|
||||
|
||||
/**
|
||||
* Mapping from deprecated capability names to their current replacements.
|
||||
*
|
||||
* Used to compare manifests across the rename without flagging spurious
|
||||
* "capability changed" prompts on upgrade, and to produce the warning
|
||||
* messages at bundle time.
|
||||
*/
|
||||
export const CAPABILITY_RENAMES: Readonly<
|
||||
Record<DeprecatedPluginCapability, CurrentPluginCapability>
|
||||
> = Object.freeze({
|
||||
"network:fetch": "network:request",
|
||||
"network:fetch:any": "network:request:unrestricted",
|
||||
"read:content": "content:read",
|
||||
"write:content": "content:write",
|
||||
"read:media": "media:read",
|
||||
"write:media": "media:write",
|
||||
"read:users": "users:read",
|
||||
"email:provide": "hooks.email-transport:register",
|
||||
"email:intercept": "hooks.email-events:register",
|
||||
"page:inject": "hooks.page-fragments:register",
|
||||
});
|
||||
|
||||
/**
|
||||
* Type guard: is this capability one of the deprecated legacy names?
|
||||
*
|
||||
* Uses an own-property check so prototype keys like "toString" don't
|
||||
* accidentally pass.
|
||||
*/
|
||||
export function isDeprecatedCapability(cap: string): cap is DeprecatedPluginCapability {
|
||||
return Object.hasOwn(CAPABILITY_RENAMES, cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a capability string -- deprecated names map to current names,
|
||||
* current names pass through unchanged. Unknown strings are returned as-is
|
||||
* so downstream validators can produce a precise error.
|
||||
*/
|
||||
export function normalizeCapability(cap: string): string {
|
||||
if (isDeprecatedCapability(cap)) {
|
||||
return CAPABILITY_RENAMES[cap];
|
||||
}
|
||||
return cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an array of capability strings, preserving order and removing
|
||||
* duplicates introduced by aliasing (e.g. a manifest declaring both
|
||||
* `network:fetch` and `network:request` should resolve to a single
|
||||
* `network:request`).
|
||||
*/
|
||||
export function normalizeCapabilities(caps: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const cap of caps) {
|
||||
const norm = normalizeCapability(cap);
|
||||
if (!seen.has(norm)) {
|
||||
seen.add(norm);
|
||||
out.push(norm);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── declaredAccess: the structured trust contract ────────────────────────────
|
||||
|
||||
/**
|
||||
* Constraint object attached to a declaredAccess operation. An open vocabulary
|
||||
* (`true` is sugar for `{}`): keys the runtime recognises are enforced, unknown
|
||||
* keys are advisory and surfaced in install-consent UI. The only normatively
|
||||
* enforced key today is `network.request.allowedHosts`.
|
||||
*/
|
||||
export type AccessConstraints = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Structured per-category access manifest -- the trust contract the registry
|
||||
* record, the bundle manifest, and the install-consent dialog all agree on.
|
||||
* Categories are host subsystems; operations are modes of participation in
|
||||
* them. Resource-access operations (read/write/request/send) gate host calls;
|
||||
* participation operations (email.events/transport, page.fragments) gate
|
||||
* privileged hook registration at load time.
|
||||
*
|
||||
* Isomorphic to a normalized `PluginCapability[]` + `allowedHosts` via
|
||||
* {@link capabilitiesToDeclaredAccess} / {@link declaredAccessToCapabilities}.
|
||||
*/
|
||||
export interface DeclaredAccess {
|
||||
content?: { read?: AccessConstraints; write?: AccessConstraints };
|
||||
taxonomies?: { read?: AccessConstraints };
|
||||
media?: { read?: AccessConstraints; write?: AccessConstraints };
|
||||
network?: { request?: { allowedHosts?: string[] } };
|
||||
email?: { send?: AccessConstraints; events?: AccessConstraints; transport?: AccessConstraints };
|
||||
page?: { fragments?: AccessConstraints };
|
||||
users?: { read?: AccessConstraints };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower a normalized capability list + `allowedHosts` into the structured
|
||||
* `declaredAccess` contract. Total over the current capability vocabulary and
|
||||
* the inverse of {@link declaredAccessToCapabilities} for implication-closed
|
||||
* inputs (the shape `definePlugin` produces).
|
||||
*
|
||||
* Network semantics are faithful to the legacy capability/allowedHosts model:
|
||||
* an ABSENT `allowedHosts` key means unrestricted (`network:request:unrestricted`);
|
||||
* a PRESENT `allowedHosts` -- even an empty array -- means host-restricted
|
||||
* (`network:request`), where the empty list is deny-all at the runtime boundary.
|
||||
* An empty list never widens to unrestricted. (The record lexicon forbids the
|
||||
* empty array and publish rejects `network:request` with no hosts, so deny-all
|
||||
* only arises for non-registry/in-process plugins.)
|
||||
*/
|
||||
export function capabilitiesToDeclaredAccess(
|
||||
capabilities: readonly string[],
|
||||
allowedHosts: readonly string[],
|
||||
): DeclaredAccess {
|
||||
const caps = new Set(capabilities.map((c) => normalizeCapability(c)));
|
||||
const out: DeclaredAccess = {};
|
||||
|
||||
if (caps.has("content:read") || caps.has("content:write")) {
|
||||
out.content = { read: {} };
|
||||
if (caps.has("content:write")) out.content.write = {};
|
||||
}
|
||||
if (caps.has("taxonomies:read")) out.taxonomies = { read: {} };
|
||||
if (caps.has("media:read") || caps.has("media:write")) {
|
||||
out.media = { read: {} };
|
||||
if (caps.has("media:write")) out.media.write = {};
|
||||
}
|
||||
if (caps.has("network:request:unrestricted")) {
|
||||
// Unrestricted: omit allowedHosts entirely (its absence is what the
|
||||
// lexicon and the decoder read as "no host restriction").
|
||||
out.network = { request: {} };
|
||||
} else if (caps.has("network:request")) {
|
||||
// Host-restricted: carry the list verbatim, INCLUDING an empty list,
|
||||
// which is deny-all at the runtime boundary. Never collapse an empty
|
||||
// list to `{}` -- that would silently widen deny-all to unrestricted.
|
||||
out.network = { request: { allowedHosts: [...allowedHosts] } };
|
||||
}
|
||||
if (caps.has("email:send")) (out.email ??= {}).send = {};
|
||||
if (caps.has("hooks.email-events:register")) (out.email ??= {}).events = {};
|
||||
if (caps.has("hooks.email-transport:register")) (out.email ??= {}).transport = {};
|
||||
if (caps.has("hooks.page-fragments:register")) out.page = { fragments: {} };
|
||||
if (caps.has("users:read")) out.users = { read: {} };
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise a `declaredAccess` block back to normalized capability strings +
|
||||
* `allowedHosts` -- the runtime's internal enforcement currency. Total: every
|
||||
* facet maps to exactly one capability. The result is closed under the same
|
||||
* implications `definePlugin` applies (write implies read; unrestricted implies
|
||||
* request), so it round-trips with {@link capabilitiesToDeclaredAccess}.
|
||||
*/
|
||||
export function declaredAccessToCapabilities(declaredAccess: DeclaredAccess): {
|
||||
capabilities: PluginCapability[];
|
||||
allowedHosts: string[];
|
||||
} {
|
||||
const caps = new Set<PluginCapability>();
|
||||
let allowedHosts: string[] = [];
|
||||
|
||||
if (declaredAccess.content?.read) caps.add("content:read");
|
||||
if (declaredAccess.content?.write) {
|
||||
caps.add("content:write");
|
||||
caps.add("content:read");
|
||||
}
|
||||
if (declaredAccess.taxonomies?.read) caps.add("taxonomies:read");
|
||||
if (declaredAccess.media?.read) caps.add("media:read");
|
||||
if (declaredAccess.media?.write) {
|
||||
caps.add("media:write");
|
||||
caps.add("media:read");
|
||||
}
|
||||
if (declaredAccess.network?.request) {
|
||||
const hosts = declaredAccess.network.request.allowedHosts;
|
||||
if (hosts === undefined) {
|
||||
// No allowedHosts key = unrestricted (lexicon semantics).
|
||||
caps.add("network:request:unrestricted");
|
||||
caps.add("network:request");
|
||||
} else {
|
||||
// allowedHosts present (even empty) = host-restricted. An empty list
|
||||
// is deny-all at the runtime boundary -- NEVER widen it to
|
||||
// unrestricted, or the most-restrictive spelling grants the most.
|
||||
caps.add("network:request");
|
||||
allowedHosts = [...hosts];
|
||||
}
|
||||
}
|
||||
if (declaredAccess.email?.send) caps.add("email:send");
|
||||
if (declaredAccess.email?.events) caps.add("hooks.email-events:register");
|
||||
if (declaredAccess.email?.transport) caps.add("hooks.email-transport:register");
|
||||
if (declaredAccess.page?.fragments) caps.add("hooks.page-fragments:register");
|
||||
if (declaredAccess.users?.read) caps.add("users:read");
|
||||
|
||||
return { capabilities: [...caps], allowedHosts };
|
||||
}
|
||||
|
||||
// ── Manifest shape ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook entry in a plugin manifest. Either a plain hook name (when the hook
|
||||
* has default priority/timeout/exclusivity) or a structured object that
|
||||
* carries the metadata.
|
||||
*
|
||||
* Authors don't write these directly -- they write `definePlugin({ hooks })`
|
||||
* or a descriptor `{ hooks }` object, and the bundler emits the right shape.
|
||||
*/
|
||||
export interface ManifestHookEntry {
|
||||
name: string;
|
||||
exclusive?: boolean;
|
||||
priority?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route entry in a plugin manifest. Either a plain route name or a structured
|
||||
* entry with the `public` flag set.
|
||||
*/
|
||||
export interface ManifestRouteEntry {
|
||||
name: string;
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-collection storage config in a plugin manifest.
|
||||
*
|
||||
* Each collection declares the indexes the host should create. Single-string
|
||||
* entries index a single field; nested arrays request composite indexes
|
||||
* (multi-column). `uniqueIndexes` carries the same shape but with a UNIQUE
|
||||
* constraint -- those entries are already queryable, no need to duplicate
|
||||
* them in `indexes`.
|
||||
*
|
||||
* Core has a stricter `StorageCollectionConfig` interface for runtime use;
|
||||
* this is the manifest-wire shape both sides agree on.
|
||||
*/
|
||||
export interface StorageCollectionConfig {
|
||||
/**
|
||||
* Indexes to create. Each entry is either a single field name or an
|
||||
* array of field names for a composite index.
|
||||
*/
|
||||
indexes: Array<string | string[]>;
|
||||
/**
|
||||
* Fields with unique constraints. Same shape as `indexes`. Unique
|
||||
* indexes are also queryable, so don't duplicate them in `indexes`.
|
||||
*/
|
||||
uniqueIndexes?: Array<string | string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin storage declaration. Maps a collection name to its index config.
|
||||
*/
|
||||
export type PluginStorageConfig = Record<string, StorageCollectionConfig>;
|
||||
|
||||
/**
|
||||
* Plugin admin surface in the manifest. Sandboxed plugins MUST NOT set the
|
||||
* `entry` field (that requires native/trusted mode); the bundler validates
|
||||
* its absence.
|
||||
*/
|
||||
export interface PluginAdminConfig {
|
||||
/** Settings form schema (Zod or JSON Schema; runtime parses). */
|
||||
settingsSchema?: unknown;
|
||||
/** Admin pages declared by the plugin (rendered via Block Kit). */
|
||||
pages?: Array<unknown>;
|
||||
/** Dashboard widgets declared by the plugin. */
|
||||
widgets?: Array<unknown>;
|
||||
/**
|
||||
* Native-only: a module specifier for a React entry. Sandboxed plugins
|
||||
* MUST NOT set this; the bundler validates the absence and the publish
|
||||
* flow refuses records that include it.
|
||||
*/
|
||||
entry?: string;
|
||||
/**
|
||||
* Native-only: trusted-mode portable text blocks. Bundler errors if a
|
||||
* sandboxed plugin declares any.
|
||||
*/
|
||||
portableTextBlocks?: Array<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The serialised manifest written to `manifest.json` inside a plugin tarball,
|
||||
* and read by the host at install/runtime. The wire contract.
|
||||
*
|
||||
* Both the bundler (writer) and the runtime (reader) MUST agree on this
|
||||
* shape; that's why it lives in this shared package rather than either side.
|
||||
*/
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
version: string;
|
||||
/**
|
||||
* The trust contract: the structured access the plugin declares. Authoritative
|
||||
* for the registry record, install consent, and the publish/install deep-equal.
|
||||
* `capabilities` and `allowedHosts` are the runtime's enforcement currency,
|
||||
* derived from this via `declaredAccessToCapabilities` at the parse boundary --
|
||||
* never the other way around.
|
||||
*
|
||||
* Optional during the migration to a declaredAccess-only wire manifest; once
|
||||
* every producer populates it (bundler, definePlugin path) and the parse
|
||||
* boundary derives the strings, this tightens to required.
|
||||
*/
|
||||
declaredAccess?: DeclaredAccess;
|
||||
capabilities: PluginCapability[];
|
||||
allowedHosts: string[];
|
||||
storage: PluginStorageConfig;
|
||||
/**
|
||||
* Hook declarations -- plain name strings (when the hook has default
|
||||
* priority/timeout/exclusivity) or structured `ManifestHookEntry` objects
|
||||
* with explicit metadata. Hook names are opaque strings at this layer;
|
||||
* core has an exhaustive union of recognised hook names internally.
|
||||
*/
|
||||
hooks: Array<ManifestHookEntry | string>;
|
||||
/** Route declarations -- plain name strings or structured objects. */
|
||||
routes: Array<ManifestRouteEntry | string>;
|
||||
admin: PluginAdminConfig;
|
||||
}
|
||||
|
||||
// ── Slug / version helpers ───────────────────────────────────────────────────
|
||||
|
||||
const SLASH_RE = /\//g;
|
||||
const LEADING_AT_RE = /^@/;
|
||||
|
||||
/**
|
||||
* Slug constraint per the registry lexicon: ASCII lowercase letter, then
|
||||
* lowercase letters / digits / hyphen / underscore, max 64 chars. The lexicon
|
||||
* description spells it out; the JSON itself only enforces minLength/maxLength
|
||||
* so we add the regex check here.
|
||||
*/
|
||||
export const PLUGIN_SLUG_RE = /^[a-z][a-z0-9_-]*$/;
|
||||
export const PLUGIN_SLUG_MAX_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Version constraint per the registry lexicon: a subset of semver 2.0 with
|
||||
* the build-metadata suffix (`+...`) explicitly disallowed (atproto record
|
||||
* keys can't contain `+`), and the version composed only of characters
|
||||
* allowed in atproto record keys.
|
||||
*
|
||||
* The shape mirrors the official semver 2.0 BNF:
|
||||
*
|
||||
* <major>.<minor>.<patch>[-<pre-release>]
|
||||
*
|
||||
* where each numeric component has no leading zeros (except the literal
|
||||
* `0`), and the optional pre-release is `.`-separated identifiers, each
|
||||
* being either a numeric (no leading zeros) or alphanumeric-with-hyphens
|
||||
* (must include a non-digit if it has hyphens).
|
||||
*
|
||||
* If you want to accept build metadata, this is the wrong type -- the
|
||||
* registry rejects it because the atproto rkey alphabet doesn't include
|
||||
* `+`. Build metadata is "ignored when comparing versions" per semver
|
||||
* anyway, so dropping it before publish is fine.
|
||||
*/
|
||||
export const PLUGIN_VERSION_RE =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$/;
|
||||
export const PLUGIN_VERSION_MAX_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Convert a plugin id (which may be a scoped npm name like
|
||||
* `@emdash-cms/sandboxed-test`) into a candidate slug suitable for use as an
|
||||
* atproto rkey. Strips a leading `@` and replaces `/` with `-`. The result
|
||||
* still needs `isPluginSlug()` validation -- callers should fail fast if
|
||||
* the manifest's id is malformed rather than relying on the PDS to reject.
|
||||
*/
|
||||
export function deriveSlugFromId(id: string): string {
|
||||
return id.replace(LEADING_AT_RE, "").replace(SLASH_RE, "-");
|
||||
}
|
||||
|
||||
export function isPluginSlug(value: string): boolean {
|
||||
return value.length > 0 && value.length <= PLUGIN_SLUG_MAX_LENGTH && PLUGIN_SLUG_RE.test(value);
|
||||
}
|
||||
|
||||
export function isPluginVersion(value: string): boolean {
|
||||
return (
|
||||
value.length > 0 && value.length <= PLUGIN_VERSION_MAX_LENGTH && PLUGIN_VERSION_RE.test(value)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CAPABILITY_RENAMES,
|
||||
capabilitiesToDeclaredAccess,
|
||||
declaredAccessToCapabilities,
|
||||
isDeprecatedCapability,
|
||||
normalizeCapabilities,
|
||||
normalizeCapability,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("isDeprecatedCapability", () => {
|
||||
it("recognises every key of CAPABILITY_RENAMES as deprecated", () => {
|
||||
for (const legacy of Object.keys(CAPABILITY_RENAMES)) {
|
||||
expect(isDeprecatedCapability(legacy)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not flag any rename target as deprecated (renames must be terminal)", () => {
|
||||
// If a rename target ended up as another deprecated name, normalization
|
||||
// would never settle. Check the closure stops in one step.
|
||||
for (const target of Object.values(CAPABILITY_RENAMES)) {
|
||||
expect(isDeprecatedCapability(target)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not flag prototype property names as deprecated", () => {
|
||||
// Object.hasOwn guard: prototype keys must not slip through.
|
||||
expect(isDeprecatedCapability("toString")).toBe(false);
|
||||
expect(isDeprecatedCapability("constructor")).toBe(false);
|
||||
expect(isDeprecatedCapability("__proto__")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCapability", () => {
|
||||
it("rewrites every legacy name to its replacement", () => {
|
||||
for (const [legacy, replacement] of Object.entries(CAPABILITY_RENAMES)) {
|
||||
expect(normalizeCapability(legacy)).toBe(replacement);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes current names through unchanged", () => {
|
||||
expect(normalizeCapability("network:request")).toBe("network:request");
|
||||
expect(normalizeCapability("content:read")).toBe("content:read");
|
||||
});
|
||||
|
||||
it("passes unknown strings through unchanged for downstream validators", () => {
|
||||
expect(normalizeCapability("not:a:real:cap")).toBe("not:a:real:cap");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCapabilities", () => {
|
||||
it("preserves order of first appearance", () => {
|
||||
expect(normalizeCapabilities(["content:read", "network:request", "media:read"])).toEqual([
|
||||
"content:read",
|
||||
"network:request",
|
||||
"media:read",
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses a legacy name into its canonical equivalent", () => {
|
||||
expect(normalizeCapabilities(["read:content"])).toEqual(["content:read"]);
|
||||
});
|
||||
|
||||
it("deduplicates when a manifest declares both the legacy and canonical forms", () => {
|
||||
// Both `network:fetch` and `network:request` are present -- after
|
||||
// normalization both become `network:request`, and the second occurrence
|
||||
// is dropped.
|
||||
expect(normalizeCapabilities(["network:fetch", "network:request"])).toEqual([
|
||||
"network:request",
|
||||
]);
|
||||
expect(normalizeCapabilities(["network:request", "network:fetch"])).toEqual([
|
||||
"network:request",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles an empty array", () => {
|
||||
expect(normalizeCapabilities([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("declaredAccess facet mapping", () => {
|
||||
it("maps each hook-registration capability to its participation facet", () => {
|
||||
expect(capabilitiesToDeclaredAccess(["hooks.email-transport:register"], [])).toEqual({
|
||||
email: { transport: {} },
|
||||
});
|
||||
expect(capabilitiesToDeclaredAccess(["hooks.email-events:register"], [])).toEqual({
|
||||
email: { events: {} },
|
||||
});
|
||||
expect(capabilitiesToDeclaredAccess(["hooks.page-fragments:register"], [])).toEqual({
|
||||
page: { fragments: {} },
|
||||
});
|
||||
expect(capabilitiesToDeclaredAccess(["users:read"], [])).toEqual({ users: { read: {} } });
|
||||
expect(capabilitiesToDeclaredAccess(["taxonomies:read"], [])).toEqual({
|
||||
taxonomies: { read: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes host-restricted from unrestricted network", () => {
|
||||
expect(capabilitiesToDeclaredAccess(["network:request"], ["api.example.com"])).toEqual({
|
||||
network: { request: { allowedHosts: ["api.example.com"] } },
|
||||
});
|
||||
// An empty constraint object is the lexicon's spelling of "unrestricted".
|
||||
expect(
|
||||
capabilitiesToDeclaredAccess(["network:request:unrestricted", "network:request"], []),
|
||||
).toEqual({ network: { request: {} } });
|
||||
});
|
||||
|
||||
it("never widens an empty allowedHosts (deny-all) to unrestricted", () => {
|
||||
// An empty allowedHosts is the most-restrictive spelling (deny-all); it must
|
||||
// never decode to unrestricted, or the tightest declaration grants the most.
|
||||
expect(capabilitiesToDeclaredAccess(["network:request"], [])).toEqual({
|
||||
network: { request: { allowedHosts: [] } },
|
||||
});
|
||||
const decoded = declaredAccessToCapabilities({ network: { request: { allowedHosts: [] } } });
|
||||
expect(decoded.capabilities).not.toContain("network:request:unrestricted");
|
||||
expect(decoded).toEqual({ capabilities: ["network:request"], allowedHosts: [] });
|
||||
});
|
||||
|
||||
it("carries every facet of an email transport that also calls out and observes events", () => {
|
||||
// declaredAccess must carry all three facets so the consent list matches
|
||||
// the capability set the runtime enforces.
|
||||
const da = capabilitiesToDeclaredAccess(
|
||||
["hooks.email-transport:register", "network:request", "hooks.email-events:register"],
|
||||
["api.cloudflare.com"],
|
||||
);
|
||||
expect(da).toEqual({
|
||||
network: { request: { allowedHosts: ["api.cloudflare.com"] } },
|
||||
email: { transport: {}, events: {} },
|
||||
});
|
||||
expect(new Set(declaredAccessToCapabilities(da).capabilities)).toEqual(
|
||||
new Set(["hooks.email-transport:register", "network:request", "hooks.email-events:register"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("declaredAccess <-> capabilities round-trip (total over the vocabulary)", () => {
|
||||
// The full enumeration of implication-closed, valid capability states.
|
||||
// definePlugin closes write->read and unrestricted->request, and publish
|
||||
// rejects network:request with no hosts, so these are the only states that
|
||||
// can reach a published manifest. Every one must round-trip to identity --
|
||||
// the guard that the two representations are isomorphic, so the consent list
|
||||
// always equals the capability set the runtime enforces.
|
||||
const contentChoices = [[], ["content:read"], ["content:read", "content:write"]];
|
||||
const mediaChoices = [[], ["media:read"], ["media:read", "media:write"]];
|
||||
const networkChoices: { caps: string[]; hosts: string[] }[] = [
|
||||
{ caps: [], hosts: [] },
|
||||
{ caps: ["network:request", "network:request:unrestricted"], hosts: [] },
|
||||
// Host-restricted with an empty allow-list = deny-all. Must round-trip
|
||||
// as restricted, never widening to unrestricted.
|
||||
{ caps: ["network:request"], hosts: [] },
|
||||
{ caps: ["network:request"], hosts: ["api.example.com"] },
|
||||
{ caps: ["network:request"], hosts: ["api.example.com", "*.cdn.example.com"] },
|
||||
];
|
||||
const singletonFacets = [
|
||||
"email:send",
|
||||
"hooks.email-events:register",
|
||||
"hooks.email-transport:register",
|
||||
"hooks.page-fragments:register",
|
||||
"users:read",
|
||||
"taxonomies:read",
|
||||
];
|
||||
|
||||
function* states() {
|
||||
for (const content of contentChoices) {
|
||||
for (const media of mediaChoices) {
|
||||
for (const network of networkChoices) {
|
||||
for (let mask = 0; mask < 1 << singletonFacets.length; mask++) {
|
||||
const extra = singletonFacets.filter((_, i) => mask & (1 << i));
|
||||
yield {
|
||||
capabilities: [...content, ...media, ...network.caps, ...extra],
|
||||
allowedHosts: network.hosts,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("recovers every implication-closed valid state exactly", () => {
|
||||
let count = 0;
|
||||
for (const input of states()) {
|
||||
const back = declaredAccessToCapabilities(
|
||||
capabilitiesToDeclaredAccess(input.capabilities, input.allowedHosts),
|
||||
);
|
||||
expect(new Set(back.capabilities)).toEqual(new Set(input.capabilities));
|
||||
expect(new Set(back.allowedHosts)).toEqual(new Set(input.allowedHosts));
|
||||
count++;
|
||||
}
|
||||
// 3 content x 3 media x 5 network x 2^6 singleton subsets.
|
||||
expect(count).toBe(2880);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
deriveSlugFromId,
|
||||
isPluginSlug,
|
||||
isPluginVersion,
|
||||
PLUGIN_SLUG_MAX_LENGTH,
|
||||
PLUGIN_VERSION_MAX_LENGTH,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("deriveSlugFromId", () => {
|
||||
it("strips a leading @ and replaces / with -", () => {
|
||||
expect(deriveSlugFromId("@emdash-cms/gallery")).toBe("emdash-cms-gallery");
|
||||
});
|
||||
|
||||
it("passes through a slug-shaped id unchanged", () => {
|
||||
expect(deriveSlugFromId("gallery")).toBe("gallery");
|
||||
});
|
||||
|
||||
it("does not normalise case (caller must follow up with isPluginSlug)", () => {
|
||||
// `Gallery` is not a valid slug; this helper only does the mechanical
|
||||
// scoped-name translation. Callers must reject the result if it fails
|
||||
// `isPluginSlug`.
|
||||
expect(deriveSlugFromId("@Acme/Gallery")).toBe("Acme-Gallery");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPluginSlug", () => {
|
||||
it("accepts canonical slugs", () => {
|
||||
expect(isPluginSlug("gallery")).toBe(true);
|
||||
expect(isPluginSlug("emdash-cms-gallery")).toBe(true);
|
||||
expect(isPluginSlug("a")).toBe(true);
|
||||
expect(isPluginSlug("a1_2-3")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty strings", () => {
|
||||
expect(isPluginSlug("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects slugs that don't start with a lowercase letter", () => {
|
||||
expect(isPluginSlug("1plugin")).toBe(false);
|
||||
expect(isPluginSlug("-plugin")).toBe(false);
|
||||
expect(isPluginSlug("_plugin")).toBe(false);
|
||||
expect(isPluginSlug("Plugin")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects slugs containing forbidden characters", () => {
|
||||
expect(isPluginSlug("Plugin/Foo")).toBe(false);
|
||||
expect(isPluginSlug("foo bar")).toBe(false);
|
||||
expect(isPluginSlug("foo.bar")).toBe(false);
|
||||
expect(isPluginSlug("foo:bar")).toBe(false);
|
||||
expect(isPluginSlug("foo@bar")).toBe(false);
|
||||
expect(isPluginSlug("🦀plugin")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects slugs over the max length", () => {
|
||||
expect(isPluginSlug("a".repeat(PLUGIN_SLUG_MAX_LENGTH))).toBe(true);
|
||||
expect(isPluginSlug("a".repeat(PLUGIN_SLUG_MAX_LENGTH + 1))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPluginVersion", () => {
|
||||
it("accepts canonical semver versions", () => {
|
||||
expect(isPluginVersion("1.0.0")).toBe(true);
|
||||
expect(isPluginVersion("0.0.1-alpha.0")).toBe(true);
|
||||
expect(isPluginVersion("10.20.30")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts canonical pre-release identifiers per semver 2.0", () => {
|
||||
expect(isPluginVersion("1.0.0-rc.1")).toBe(true);
|
||||
expect(isPluginVersion("1.0.0-alpha")).toBe(true);
|
||||
expect(isPluginVersion("1.0.0-alpha.1")).toBe(true);
|
||||
expect(isPluginVersion("1.0.0-0.3.7")).toBe(true);
|
||||
expect(isPluginVersion("1.0.0-x.7.z.92")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects build-metadata suffixes (semver `+` is disallowed)", () => {
|
||||
expect(isPluginVersion("1.0.0+build")).toBe(false);
|
||||
expect(isPluginVersion("1.0.0+build.1")).toBe(false);
|
||||
expect(isPluginVersion("1.0.0-rc.0+build")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects leading-zero numeric components (per semver)", () => {
|
||||
expect(isPluginVersion("01.0.0")).toBe(false);
|
||||
expect(isPluginVersion("1.02.0")).toBe(false);
|
||||
expect(isPluginVersion("1.0.03")).toBe(false);
|
||||
// Pre-release numerics also disallow leading zeros.
|
||||
expect(isPluginVersion("1.0.0-01")).toBe(false);
|
||||
expect(isPluginVersion("1.0.0-rc.01")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed versions the previous loose regex accepted", () => {
|
||||
expect(isPluginVersion(".1.0.0")).toBe(false);
|
||||
expect(isPluginVersion("1.0.0-")).toBe(false);
|
||||
expect(isPluginVersion("-")).toBe(false);
|
||||
expect(isPluginVersion(".")).toBe(false);
|
||||
expect(isPluginVersion("foo")).toBe(false);
|
||||
expect(isPluginVersion("1.0")).toBe(false); // missing patch
|
||||
expect(isPluginVersion("1")).toBe(false);
|
||||
// Note: `1.0.0--rc` is technically valid semver — `-rc` is an
|
||||
// alphanumeric identifier with leading hyphen — even though it looks
|
||||
// suspicious. We accept it.
|
||||
expect(isPluginVersion("1.0.0--rc")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects path-traversal and shell-control characters", () => {
|
||||
expect(isPluginVersion("../etc/passwd")).toBe(false);
|
||||
expect(isPluginVersion("1.0.0; rm -rf /")).toBe(false);
|
||||
expect(isPluginVersion("1.0.0:tag")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects underscore and tilde even though atproto rkeys would accept them", () => {
|
||||
expect(isPluginVersion("1_0_0")).toBe(false);
|
||||
expect(isPluginVersion("1~0~0")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty strings", () => {
|
||||
expect(isPluginVersion("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects versions over the max length", () => {
|
||||
// Construct exactly-max-length: "1." repeated, capped to keep semver shape.
|
||||
// Use a prerelease tail to inflate length within the format.
|
||||
const shortValid = "1.0.0";
|
||||
expect(isPluginVersion(shortValid)).toBe(true);
|
||||
// Synthesize a max-length valid: "1.0.0-" + identifier of length (max-6).
|
||||
const tail = "a".repeat(PLUGIN_VERSION_MAX_LENGTH - 6);
|
||||
const exactlyMax = `1.0.0-${tail}`;
|
||||
expect(exactlyMax.length).toBe(PLUGIN_VERSION_MAX_LENGTH);
|
||||
expect(isPluginVersion(exactlyMax)).toBe(true);
|
||||
expect(isPluginVersion(`${exactlyMax}b`)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"lib": ["es2023", "esnext.typedarrays"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
outExtensions: () => ({ js: ".js" }),
|
||||
dts: true,
|
||||
clean: true,
|
||||
platform: "neutral",
|
||||
target: "es2023",
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user