Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
513 lines
15 KiB
TypeScript
513 lines
15 KiB
TypeScript
/**
|
|
* Runtime utilities for EmDash
|
|
*
|
|
* This file contains functions that are used at runtime (in middleware, routes, etc.)
|
|
* and must work in all environments including Cloudflare Workers.
|
|
*
|
|
* DO NOT import Node.js-only modules here (fs, path, module, etc.)
|
|
*/
|
|
|
|
import type { AuthDescriptor, AuthProviderDescriptor } from "../../auth/types.js";
|
|
import type { DatabaseDescriptor } from "../../db/adapters.js";
|
|
import type { MediaProviderDescriptor } from "../../media/types.js";
|
|
import type { ResolvedPlugin } from "../../plugins/types.js";
|
|
import type { StorageDescriptor } from "../storage/types.js";
|
|
|
|
export type { ResolvedPlugin };
|
|
export type { MediaProviderDescriptor };
|
|
|
|
/**
|
|
* Admin page definition (copied from plugins/types to avoid circular deps)
|
|
*/
|
|
export interface PluginAdminPage {
|
|
path: string;
|
|
label: string;
|
|
icon?: string;
|
|
}
|
|
|
|
/**
|
|
* Dashboard widget definition (copied from plugins/types to avoid circular deps)
|
|
*/
|
|
export interface PluginDashboardWidget {
|
|
id: string;
|
|
size?: "full" | "half" | "third";
|
|
title?: string;
|
|
}
|
|
|
|
/**
|
|
* Plugin descriptor - returned by plugin factory functions
|
|
*
|
|
* Contains all static metadata needed for manifest and admin UI,
|
|
* plus the entrypoint for runtime instantiation.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* export function myPlugin(options?: MyPluginOptions): PluginDescriptor {
|
|
* return {
|
|
* id: "my-plugin",
|
|
* version: "1.0.0",
|
|
* entrypoint: "@my-org/emdash-plugin-foo",
|
|
* options: options ?? {},
|
|
* adminEntry: "@my-org/emdash-plugin-foo/admin",
|
|
* adminPages: [{ path: "/settings", label: "Settings" }],
|
|
* };
|
|
* }
|
|
* ```
|
|
*/
|
|
/**
|
|
* Storage collection declaration for sandboxed plugins
|
|
*/
|
|
export interface StorageCollectionDeclaration {
|
|
indexes?: string[];
|
|
uniqueIndexes?: string[];
|
|
}
|
|
|
|
export interface PluginDescriptor<TOptions = Record<string, unknown>> {
|
|
/** Unique plugin identifier */
|
|
id: string;
|
|
/** Plugin version (semver) */
|
|
version: string;
|
|
/** Module specifier to import (e.g., "@emdash-cms/plugin-api-test") */
|
|
entrypoint: string;
|
|
/**
|
|
* Options to pass to createPlugin(). Native format only.
|
|
* Standard-format plugins configure themselves via KV settings
|
|
* and Block Kit admin pages -- not constructor options.
|
|
*/
|
|
options?: TOptions;
|
|
/**
|
|
* Plugin format. Determines how the entrypoint is loaded:
|
|
* - `"standard"` -- exports `definePlugin({ hooks, routes })` as default.
|
|
* Wrapped with `adaptSandboxEntry` for in-process execution. Can run in both
|
|
* `plugins: []` (in-process) and `sandboxed: []` (isolate).
|
|
* - `"native"` -- exports `createPlugin(options)` returning a `ResolvedPlugin`.
|
|
* Can only run in `plugins: []`. Cannot be sandboxed or published to marketplace.
|
|
*
|
|
* Defaults to `"native"` when unset.
|
|
*
|
|
*/
|
|
format?: "standard" | "native";
|
|
/** Admin UI module specifier (e.g., "@emdash-cms/plugin-audit-log/admin") */
|
|
adminEntry?: string;
|
|
/** Module specifier for site-side Astro rendering components (must export `blockComponents`) */
|
|
componentsEntry?: string;
|
|
/** Admin pages for navigation */
|
|
adminPages?: PluginAdminPage[];
|
|
/** Dashboard widgets */
|
|
adminWidgets?: PluginDashboardWidget[];
|
|
|
|
// === Sandbox-specific fields (for sandboxed plugins) ===
|
|
|
|
/**
|
|
* Capabilities the plugin requests.
|
|
* For standard-format plugins, capabilities are enforced in both trusted and
|
|
* sandboxed modes via the PluginContextFactory.
|
|
*/
|
|
capabilities?: string[];
|
|
/**
|
|
* Allowed hosts for network:fetch capability
|
|
* Supports wildcards like "*.example.com"
|
|
*/
|
|
allowedHosts?: string[];
|
|
/**
|
|
* Storage collections the plugin declares
|
|
* Sandboxed plugins can only access declared collections.
|
|
*/
|
|
storage?: Record<string, StorageCollectionDeclaration>;
|
|
}
|
|
|
|
/**
|
|
* Sandboxed plugin descriptor - same format as PluginDescriptor
|
|
*
|
|
* These run in isolated V8 isolates via Worker Loader on Cloudflare.
|
|
* The `entrypoint` is resolved to a file and bundled at build time.
|
|
*/
|
|
export type SandboxedPluginDescriptor<TOptions = Record<string, unknown>> =
|
|
PluginDescriptor<TOptions>;
|
|
|
|
export interface EmDashConfig {
|
|
/**
|
|
* Database configuration
|
|
*
|
|
* Use one of the adapter functions:
|
|
* - `sqlite({ url: "file:./data.db" })` - Local SQLite
|
|
* - `libsql({ url: "...", authToken: "..." })` - Turso/libSQL
|
|
* - `d1({ binding: "DB" })` - Cloudflare D1
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* import { sqlite } from "emdash/db";
|
|
*
|
|
* emdash({
|
|
* database: sqlite({ url: "file:./data.db" }),
|
|
* })
|
|
* ```
|
|
*/
|
|
database?: DatabaseDescriptor;
|
|
/**
|
|
* Storage configuration (for media)
|
|
*/
|
|
storage?: StorageDescriptor;
|
|
/**
|
|
* Trusted plugins to load (run in main isolate)
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* import { auditLogPlugin } from "@emdash-cms/plugin-audit-log";
|
|
* import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier";
|
|
*
|
|
* emdash({
|
|
* plugins: [
|
|
* auditLogPlugin(),
|
|
* webhookNotifierPlugin({ url: "https://example.com/webhook" }),
|
|
* ],
|
|
* })
|
|
* ```
|
|
*/
|
|
plugins?: PluginDescriptor[];
|
|
/**
|
|
* Sandboxed plugins to load (run in isolated V8 isolates)
|
|
*
|
|
* Only works on Cloudflare with Worker Loader enabled.
|
|
* Uses the same format as `plugins` - the difference is where they run.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* import { untrustedPlugin } from "some-third-party-plugin";
|
|
*
|
|
* emdash({
|
|
* plugins: [trustedPlugin()], // runs in host
|
|
* sandboxed: [untrustedPlugin()], // runs in isolate
|
|
* sandboxRunner: "@emdash-cms/sandbox-cloudflare",
|
|
* })
|
|
* ```
|
|
*/
|
|
sandboxed?: SandboxedPluginDescriptor[];
|
|
/**
|
|
* Module that exports the sandbox runner factory.
|
|
* Required if using sandboxed plugins.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* emdash({
|
|
* sandboxRunner: "@emdash-cms/sandbox-cloudflare",
|
|
* })
|
|
* ```
|
|
*/
|
|
sandboxRunner?: string;
|
|
|
|
/**
|
|
* Authentication configuration
|
|
*
|
|
* Use an auth adapter function from a platform package:
|
|
* - `access({ teamDomain: "..." })` from `@emdash-cms/cloudflare`
|
|
*
|
|
* When an external auth provider is configured, passkey auth is disabled.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* import { access } from "@emdash-cms/cloudflare";
|
|
*
|
|
* emdash({
|
|
* auth: access({
|
|
* teamDomain: "myteam.cloudflareaccess.com",
|
|
* audience: "abc123...",
|
|
* roleMapping: {
|
|
* "Admins": 50,
|
|
* "Editors": 30,
|
|
* },
|
|
* }),
|
|
* })
|
|
* ```
|
|
*/
|
|
auth?: AuthDescriptor;
|
|
|
|
/**
|
|
* Pluggable auth providers (login methods on the login page).
|
|
*
|
|
* Auth providers appear as options alongside passkey on the login page
|
|
* and setup wizard. Any provider can be used to create the initial
|
|
* admin account. Passkey is built-in; providers listed here are additive.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* import { atproto } from "@emdash-cms/auth-atproto";
|
|
*
|
|
* emdash({
|
|
* authProviders: [atproto()],
|
|
* })
|
|
* ```
|
|
*/
|
|
authProviders?: AuthProviderDescriptor[];
|
|
|
|
/**
|
|
* MCP (Model Context Protocol) server endpoint.
|
|
*
|
|
* Exposes an MCP Streamable HTTP server at `/_emdash/api/mcp`
|
|
* that allows AI agents and tools to interact with the CMS using
|
|
* the standardized MCP protocol.
|
|
*
|
|
* Enabled by default. The endpoint requires bearer token auth, so
|
|
* it has no effect unless the user creates an API token and
|
|
* configures a client. Set to `false` to disable.
|
|
*
|
|
* @default true
|
|
*/
|
|
mcp?: boolean;
|
|
|
|
/**
|
|
* Plugin marketplace URL
|
|
*
|
|
* When set, enables the marketplace features: browse, install, update,
|
|
* and uninstall plugins from a remote marketplace.
|
|
*
|
|
* Must be an HTTPS URL in production, or localhost/127.0.0.1 in dev.
|
|
* Requires `sandboxRunner` to be configured (marketplace plugins run sandboxed).
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* emdash({
|
|
* marketplace: "https://marketplace.emdashcms.com",
|
|
* sandboxRunner: "@emdash-cms/sandbox-cloudflare",
|
|
* })
|
|
* ```
|
|
*/
|
|
marketplace?: string;
|
|
|
|
/**
|
|
* Maximum allowed media file upload size in bytes.
|
|
*
|
|
* Applies to both direct multipart uploads and signed-URL uploads.
|
|
* When unset, defaults to 52_428_800 (50 MB).
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* emdash({ maxUploadSize: 100 * 1024 * 1024 }) // 100 MB
|
|
* ```
|
|
*/
|
|
maxUploadSize?: number;
|
|
|
|
/**
|
|
* Public browser-facing origin for the site.
|
|
*
|
|
* Use when `Astro.url` / `request.url` do not match what users open — common with a
|
|
* **TLS-terminating reverse proxy**: the app often sees `http://` on the internal hop
|
|
* while the browser uses `https://`, which breaks WebAuthn, CSRF, OAuth, and redirect URLs.
|
|
*
|
|
* Set to the full origin users type in the address bar (no path), e.g.
|
|
* `https://mysite.example.com`. When not set, falls back to environment variables
|
|
* `EMDASH_SITE_URL` > `SITE_URL`, then to the request URL's origin.
|
|
*
|
|
* Replaces `passkeyPublicOrigin` (which only fixed passkeys).
|
|
*/
|
|
siteUrl?: string;
|
|
|
|
/**
|
|
* Additional origins accepted by passkey verification.
|
|
*
|
|
* When the same EmDash deployment is reachable under several hostnames sharing
|
|
* a registrable parent (e.g. `https://example.com` plus
|
|
* `https://preview.example.com`), the canonical `siteUrl` defines the `rpId`
|
|
* and the entries here are the *additional* origins from which assertions
|
|
* are accepted. Each entry must be the same hostname as `siteUrl` or a
|
|
* subdomain of it — WebAuthn requires `rpId` to be a registrable suffix of
|
|
* every origin.
|
|
*
|
|
* Merged at runtime with the `EMDASH_ALLOWED_ORIGINS` env var (comma-separated).
|
|
* Validation:
|
|
* - Config-declared entries are shape-checked at Astro startup.
|
|
* - Subdomain relationship to `siteUrl` is checked at startup when
|
|
* `siteUrl` is also config-declared, otherwise at first passkey
|
|
* verification (since `siteUrl` may come from `EMDASH_SITE_URL`).
|
|
*
|
|
* Mismatches throw with a source-attributed message naming
|
|
* `config.allowedOrigins` or `EMDASH_ALLOWED_ORIGINS`.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* emdash({
|
|
* siteUrl: "https://example.com",
|
|
* allowedOrigins: ["https://preview.example.com"],
|
|
* })
|
|
* ```
|
|
*/
|
|
allowedOrigins?: string[];
|
|
/*
|
|
* Headers to trust for client IP resolution when running behind a reverse
|
|
* proxy. The first header in this list that is present on the request
|
|
* wins. Applies to rate limiting for auth endpoints and comment
|
|
* submission.
|
|
*
|
|
* Common values:
|
|
* - `x-real-ip` — nginx, Caddy, Traefik
|
|
* - `fly-client-ip` — Fly.io
|
|
* - `x-forwarded-for` — generic (first entry is used)
|
|
*
|
|
* Only set this when you **control the reverse proxy**. Untrusted
|
|
* clients can set any header they like; trusting headers from an open
|
|
* network is an IP-spoofing vulnerability that defeats rate limiting.
|
|
*
|
|
* On Cloudflare the `cf` object on the request is used automatically —
|
|
* you normally don't need to set this. Leave unset (or empty) to
|
|
* preserve the default: IP is resolved only when the request came
|
|
* through Cloudflare's edge.
|
|
*
|
|
* Falls back to `EMDASH_TRUSTED_PROXY_HEADERS` env var (comma-separated)
|
|
* when this option is not set, so operators can configure at deploy
|
|
* time without touching the Astro config.
|
|
*/
|
|
trustedProxyHeaders?: string[];
|
|
|
|
/**
|
|
* Enable playground mode for ephemeral "try EmDash" sites.
|
|
*
|
|
* When set, the integration injects a playground middleware (order: "pre")
|
|
* that runs BEFORE the normal EmDash middleware chain. It creates an
|
|
* isolated Durable Object database per session, runs migrations, applies
|
|
* the seed, creates an anonymous admin user, and sets the DB in ALS.
|
|
* By the time the runtime middleware runs, the database is fully ready.
|
|
*
|
|
* Setup and auth middleware are skipped (the playground handles both).
|
|
*
|
|
* Requires `@emdash-cms/cloudflare` as a dependency and a DO binding
|
|
* in wrangler.jsonc.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* emdash({
|
|
* database: playgroundDatabase({ binding: "PLAYGROUND_DB" }),
|
|
* playground: {
|
|
* middlewareEntrypoint: "@emdash-cms/cloudflare/db/playground-middleware",
|
|
* },
|
|
* })
|
|
* ```
|
|
*/
|
|
playground?: {
|
|
/** Module path for the playground middleware. */
|
|
middlewareEntrypoint: string;
|
|
};
|
|
|
|
/**
|
|
* Media providers for browsing and uploading media
|
|
*
|
|
* The local media provider (using storage adapter) is available by default.
|
|
* Additional providers can be added for external services like Unsplash,
|
|
* Cloudinary, Mux, Cloudflare Images, etc.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* import { cloudflareImages, cloudflareStream } from "@emdash-cms/cloudflare";
|
|
* import { unsplash } from "@emdash-cms/provider-unsplash";
|
|
*
|
|
* emdash({
|
|
* mediaProviders: [
|
|
* cloudflareImages({ accountId: "..." }),
|
|
* cloudflareStream({ accountId: "..." }),
|
|
* unsplash({ accessKey: "..." }),
|
|
* ],
|
|
* })
|
|
* ```
|
|
*/
|
|
mediaProviders?: MediaProviderDescriptor[];
|
|
|
|
/**
|
|
* Admin UI font configuration.
|
|
*
|
|
* By default, EmDash loads Noto Sans via the Astro Font API, covering
|
|
* Latin, Latin Extended, Cyrillic, Cyrillic Extended, Greek, Greek
|
|
* Extended, Devanagari, and Vietnamese. Fonts are downloaded from
|
|
* Google at build time and self-hosted, so there are no runtime CDN
|
|
* requests.
|
|
*
|
|
* To add support for additional writing systems (Arabic, CJK, etc.),
|
|
* pass script names. EmDash resolves the matching Noto Sans variant
|
|
* from Google Fonts and merges all script faces under a single
|
|
* font-family, so the browser downloads only the glyphs it needs
|
|
* via unicode-range.
|
|
*
|
|
* Set to `false` to disable font injection entirely and use system fonts.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Add Arabic and Japanese support
|
|
* emdash({
|
|
* fonts: {
|
|
* scripts: ["arabic", "japanese"],
|
|
* },
|
|
* })
|
|
* ```
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Disable web fonts entirely (use system fonts)
|
|
* emdash({
|
|
* fonts: false,
|
|
* })
|
|
* ```
|
|
*/
|
|
fonts?:
|
|
| false
|
|
| {
|
|
/**
|
|
* Additional Noto Sans script families to include.
|
|
*
|
|
* Available scripts: arabic, armenian, bengali, chinese-simplified,
|
|
* chinese-traditional, chinese-hongkong, devanagari, ethiopic, farsi,
|
|
* georgian, gujarati, gurmukhi, hebrew, japanese, kannada, khmer,
|
|
* korean, lao, malayalam, myanmar, oriya, sinhala, tamil, telugu,
|
|
* thai, tibetan.
|
|
*/
|
|
scripts?: string[];
|
|
};
|
|
|
|
/**
|
|
* Admin UI branding (white-labeling).
|
|
*
|
|
* Overrides the default EmDash logo and name in the admin panel.
|
|
* Use this to white-label the CMS for agency or enterprise deployments.
|
|
* These settings are separate from the public site settings (title, logo,
|
|
* favicon) which remain available for SEO and front-end use.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* emdash({
|
|
* admin: {
|
|
* logo: "/images/agency-logo.webp",
|
|
* siteName: "AgencyX CMS",
|
|
* favicon: "/favicon.ico",
|
|
* },
|
|
* })
|
|
* ```
|
|
*/
|
|
admin?: {
|
|
/** URL or path to a custom logo image for the admin UI (login page, sidebar). */
|
|
logo?: string;
|
|
/** Custom name displayed in the admin sidebar and browser tab. */
|
|
siteName?: string;
|
|
/** URL or path to a custom favicon for the admin panel. */
|
|
favicon?: string;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get stored config from global
|
|
* This is set by the virtual module at build time
|
|
*/
|
|
export function getStoredConfig(): EmDashConfig | null {
|
|
return globalThis.__emdashConfig || null;
|
|
}
|
|
|
|
/**
|
|
* Set stored config in global
|
|
* Called by the integration at config time
|
|
*/
|
|
export function setStoredConfig(config: EmDashConfig): void {
|
|
globalThis.__emdashConfig = config;
|
|
}
|
|
|
|
// Declare global type
|
|
declare global {
|
|
// eslint-disable-next-line no-var
|
|
var __emdashConfig: EmDashConfig | undefined;
|
|
}
|