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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:53 +08:00
commit b3a7f98e5a
3020 changed files with 935484 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
*.tsbuildinfo
+19
View File
@@ -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.
+46
View File
@@ -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"
}
+466
View File
@@ -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);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["es2023", "esnext.typedarrays"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
+11
View File
@@ -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",
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
},
});