This commit is contained in:
Matt Kane
2026-04-01 11:51:57 +01:00
parent c8e318da5c
commit ca3c2b77e1
88 changed files with 313 additions and 481 deletions

View File

@@ -329,9 +329,7 @@ export function MarketplacePluginDetail({
<h3 className="text-sm font-semibold mb-2">Version</h3>
<div className="space-y-1 text-xs text-kumo-subtle">
<div>v{latest.version}</div>
{latest.minEmDashVersion && (
<div>Requires EmDash {latest.minEmDashVersion}</div>
)}
{latest.minEmDashVersion && <div>Requires EmDash {latest.minEmDashVersion}</div>}
<div>Published {new Date(latest.publishedAt).toLocaleDateString()}</div>
{latest.bundleSize > 0 && <div>{formatBytes(latest.bundleSize)}</div>}
</div>

View File

@@ -12,6 +12,7 @@
*/
import { Button, Dialog, Input } from "@cloudflare/kumo";
import type { Element } from "@emdash-cms/blocks";
import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react";
import {
TextB,
@@ -41,7 +42,6 @@ import {
type Icon,
} from "@phosphor-icons/react";
import { X } from "@phosphor-icons/react";
import type { Element } from "@emdash-cms/blocks";
import { Extension, type Range } from "@tiptap/core";
import CharacterCount from "@tiptap/extension-character-count";
import Focus from "@tiptap/extension-focus";

View File

@@ -5,9 +5,9 @@
* interactions to the plugin's admin route and renders the returned blocks.
*/
import { CircleNotch, WarningCircle } from "@phosphor-icons/react";
import { BlockRenderer } from "@emdash-cms/blocks";
import type { Block, BlockInteraction, BlockResponse } from "@emdash-cms/blocks";
import { CircleNotch, WarningCircle } from "@phosphor-icons/react";
import { useCallback, useEffect, useState } from "react";
import { apiFetch, API_BASE } from "../lib/api/client.js";

View File

@@ -5,9 +5,9 @@
* interaction with page="widget:<widgetId>" to the plugin's admin route.
*/
import { CircleNotch } from "@phosphor-icons/react";
import { BlockRenderer } from "@emdash-cms/blocks";
import type { Block, BlockInteraction, BlockResponse } from "@emdash-cms/blocks";
import { CircleNotch } from "@phosphor-icons/react";
import { useCallback, useEffect, useState } from "react";
import { apiFetch, API_BASE } from "../lib/api/client.js";

View File

@@ -720,9 +720,7 @@ export function WordPressImport() {
<div className="rounded-lg border bg-kumo-base p-12 text-center">
<Loader />
<p className="mt-4 text-kumo-subtle">Analyzing WordPress site...</p>
<p className="text-sm text-kumo-subtle">
Fetching content from the EmDash Exporter API.
</p>
<p className="text-sm text-kumo-subtle">Fetching content from the EmDash Exporter API.</p>
</div>
)}

View File

@@ -10,6 +10,7 @@
*/
import { Button, Input } from "@cloudflare/kumo";
import type { Element } from "@emdash-cms/blocks";
import {
DotsSixVertical,
Trash,
@@ -24,7 +25,6 @@ import {
Cube,
ListBullets,
} from "@phosphor-icons/react";
import type { Element } from "@emdash-cms/blocks";
import { Node, mergeAttributes } from "@tiptap/core";
import type { NodeViewProps } from "@tiptap/react";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";

View File

@@ -18,9 +18,7 @@ describe("sanitizeRedirectUrl", () => {
});
it("allows paths with query strings", () => {
expect(sanitizeRedirectUrl("/_emdash/admin?tab=settings")).toBe(
"/_emdash/admin?tab=settings",
);
expect(sanitizeRedirectUrl("/_emdash/admin?tab=settings")).toBe("/_emdash/admin?tab=settings");
});
it("allows paths with hash fragments", () => {
@@ -44,9 +42,7 @@ describe("sanitizeRedirectUrl", () => {
});
it("rejects data: scheme", () => {
expect(sanitizeRedirectUrl("data:text/html,<script>alert(1)</script>")).toBe(
"/_emdash/admin",
);
expect(sanitizeRedirectUrl("data:text/html,<script>alert(1)</script>")).toBe("/_emdash/admin");
});
it("rejects backslash trick (/\\evil.com)", () => {

View File

@@ -1,6 +1,6 @@
import { Sun, Moon, Share, Check, Trash, CaretDown, Warning, Plus } from "@phosphor-icons/react";
import { BlockRenderer, validateBlocks } from "@emdash-cms/blocks";
import type { Block, BlockInteraction } from "@emdash-cms/blocks";
import { Sun, Moon, Share, Check, Trash, CaretDown, Warning, Plus } from "@phosphor-icons/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { blockCatalog } from "./block-defaults";

View File

@@ -4,4 +4,4 @@
"assets": {
"directory": "./dist",
},
}
}

View File

@@ -10,8 +10,8 @@
* Do not import at config time.
*/
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import type { AuthResult } from "emdash";
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
/**
* Configuration for Cloudflare Access authentication

View File

@@ -22,8 +22,8 @@
import type { MiddlewareHandler } from "astro";
import { env } from "cloudflare:workers";
import { Kysely } from "kysely";
import { runWithContext } from "emdash/request-context";
import { Kysely } from "kysely";
import { ulid } from "ulidx";
import type { EmDashPreviewDB } from "./do-class.js";

View File

@@ -61,11 +61,7 @@ export class R2Storage implements Storage {
};
} catch (error) {
if (error instanceof EmDashStorageError) throw error;
throw new EmDashStorageError(
`Failed to upload file: ${options.key}`,
"UPLOAD_FAILED",
error,
);
throw new EmDashStorageError(`Failed to upload file: ${options.key}`, "UPLOAD_FAILED", error);
}
}
@@ -107,11 +103,7 @@ export class R2Storage implements Storage {
const object = await this.bucket.head(key);
return object !== null;
} catch (error) {
throw new EmDashStorageError(
`Failed to check file existence: ${key}`,
"HEAD_FAILED",
error,
);
throw new EmDashStorageError(`Failed to check file existence: ${key}`, "HEAD_FAILED", error);
}
}

View File

@@ -664,10 +664,7 @@ export async function handleTokenRevoke(
if (row.token_type === "refresh") {
// Revoke refresh token and all its access tokens
await db
.deleteFrom("_emdash_oauth_tokens")
.where("refresh_token_hash", "=", hash)
.execute();
await db.deleteFrom("_emdash_oauth_tokens").where("refresh_token_hash", "=", hash).execute();
await db.deleteFrom("_emdash_oauth_tokens").where("token_hash", "=", hash).execute();
} else {
// Revoke just the access token

View File

@@ -236,11 +236,7 @@ export async function handleOAuthClientUpdate(
updates.scopes = input.scopes ? JSON.stringify(input.scopes) : "";
}
await db
.updateTable("_emdash_oauth_clients")
.set(updates)
.where("id", "=", clientId)
.execute();
await db.updateTable("_emdash_oauth_clients").set(updates).where("id", "=", clientId).execute();
// Fetch the updated row
const updated = await db

View File

@@ -95,11 +95,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
if (newStatus === "approved" && previousStatus !== "approved" && emdash.email) {
try {
const adminBaseUrl = await getSiteBaseUrl(emdash.db, request);
const content = await lookupContentAuthor(
emdash.db,
updated.collection,
updated.contentId,
);
const content = await lookupContentAuthor(emdash.db, updated.collection, updated.contentId);
if (content?.author) {
await sendCommentNotification({
email: emdash.email,

View File

@@ -71,9 +71,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Get passkey name - prefer body.name, then check stored pending name
let passKeyName: string | undefined = body.name ?? undefined;
if (!passKeyName) {
const pending = await optionsRepo.get<{ name?: string }>(
`emdash:passkey_pending:${user.id}`,
);
const pending = await optionsRepo.get<{ name?: string }>(`emdash:passkey_pending:${user.id}`);
if (pending?.name) {
passKeyName = pending.name;
}

View File

@@ -59,11 +59,7 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
const denied = requireOwnerPerm(user, authorId, "content:publish_own", "content:publish_any");
if (denied) return denied;
const result = await emdash.handleContentSchedule(
collection,
resolvedId ?? id,
body.scheduledAt,
);
const result = await emdash.handleContentSchedule(collection, resolvedId ?? id, body.scheduledAt);
if (!result.success) return unwrapResult(result);

View File

@@ -8,8 +8,8 @@
*/
import type { APIRoute } from "astro";
import mime from "mime/lite";
import { parseWxrString, SchemaRegistry, type WxrData } from "emdash";
import mime from "mime/lite";
import { requirePerm } from "#api/authorize.js";
import { apiError, apiSuccess, handleError } from "#api/error.js";

View File

@@ -11,8 +11,8 @@
import * as path from "node:path";
import type { APIRoute } from "astro";
import mime from "mime/lite";
import { MediaRepository, computeContentHash } from "emdash";
import mime from "mime/lite";
import { ulid } from "ulidx";
import { requirePerm } from "#api/authorize.js";

View File

@@ -15,11 +15,7 @@ export const POST: APIRoute = async ({ params, locals }) => {
const { emdash, user } = locals;
const revisionId = params.revisionId!;
if (
!emdash?.handleRevisionRestore ||
!emdash?.handleRevisionGet ||
!emdash?.handleContentGet
) {
if (!emdash?.handleRevisionRestore || !emdash?.handleRevisionGet || !emdash?.handleContentGet) {
return apiError("NOT_CONFIGURED", "EmDash not configured", 500);
}

View File

@@ -57,11 +57,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
// Update sort_order for each widget
await Promise.all(
body.widgetIds.map((id, index) =>
db
.updateTable("_emdash_widgets")
.set({ sort_order: index })
.where("id", "=", id)
.execute(),
db.updateTable("_emdash_widgets").set({ sort_order: index }).where("id", "=", id).execute(),
),
);

View File

@@ -418,9 +418,7 @@ export const publishCommand = defineCommand({
process.exit(1);
}
} catch {
consola.error(
"No dist/ directory found. Run `emdash plugin bundle` first or use --build.",
);
consola.error("No dist/ directory found. Run `emdash plugin bundle` first or use --build.");
process.exit(1);
}
}

View File

@@ -135,9 +135,7 @@ export const seedCommand = defineCommand({
const seedPath = await resolveSeedPath(cwd, args.path);
if (!seedPath) {
consola.error("No seed file found");
consola.info(
"Provide a path, create .emdash/seed.json, or set emdash.seed in package.json",
);
consola.info("Provide a path, create .emdash/seed.json, or set emdash.seed in package.json");
process.exit(1);
}

View File

@@ -27,10 +27,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
.execute();
// ── Device code polling tracking ─────────────────────────────────
await db.schema
.alterTable("_emdash_device_codes")
.addColumn("last_polled_at", "text")
.execute();
await db.schema.alterTable("_emdash_device_codes").addColumn("last_polled_at", "text").execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {

View File

@@ -9,10 +9,10 @@
* The handlers instance is passed per-request via authInfo on the transport.
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import type { Permission, RoleLevel } from "@emdash-cms/auth";
import { canActOnOwn, Role } from "@emdash-cms/auth";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import type { EmDashHandlers } from "../astro/types.js";

View File

@@ -103,11 +103,7 @@ export class LocalStorage implements Storage {
size: buffer.length,
};
} catch (error) {
throw new EmDashStorageError(
`Failed to upload file: ${options.key}`,
"UPLOAD_FAILED",
error,
);
throw new EmDashStorageError(`Failed to upload file: ${options.key}`, "UPLOAD_FAILED", error);
}
}

View File

@@ -97,11 +97,7 @@ export class S3Storage implements Storage {
size: body.length,
};
} catch (error) {
throw new EmDashStorageError(
`Failed to upload file: ${options.key}`,
"UPLOAD_FAILED",
error,
);
throw new EmDashStorageError(`Failed to upload file: ${options.key}`, "UPLOAD_FAILED", error);
}
}
@@ -166,11 +162,7 @@ export class S3Storage implements Storage {
if (hasErrorName(error) && error.name === "NotFound") {
return false;
}
throw new EmDashStorageError(
`Failed to check file existence: ${key}`,
"HEAD_FAILED",
error,
);
throw new EmDashStorageError(`Failed to check file existence: ${key}`, "HEAD_FAILED", error);
}
}

View File

@@ -172,11 +172,7 @@ describe("Device Flow: Full Lifecycle", () => {
});
it("should handle denied authorization", async () => {
const codeResult = await handleDeviceCodeRequest(
db,
{},
"https://example.com/_emdash/device",
);
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
@@ -199,11 +195,7 @@ describe("Device Flow: Full Lifecycle", () => {
});
it("should normalize user codes (strip hyphens, case-insensitive)", async () => {
const codeResult = await handleDeviceCodeRequest(
db,
{},
"https://example.com/_emdash/device",
);
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
@@ -263,11 +255,7 @@ describe("Device Token Exchange: Error Cases", () => {
describe("Token Refresh", () => {
it("should exchange a refresh token for a new access token", async () => {
// Complete a device flow first to get tokens
const codeResult = await handleDeviceCodeRequest(
db,
{},
"https://example.com/_emdash/device",
);
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
@@ -330,11 +318,7 @@ describe("Token Refresh", () => {
describe("Token Revoke", () => {
it("should revoke an access token", async () => {
// Get tokens via device flow
const codeResult = await handleDeviceCodeRequest(
db,
{},
"https://example.com/_emdash/device",
);
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
if (!codeResult.success) return;
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
@@ -365,11 +349,7 @@ describe("Token Revoke", () => {
it("should revoke a refresh token and its access tokens", async () => {
// Get tokens via device flow
const codeResult = await handleDeviceCodeRequest(
db,
{},
"https://example.com/_emdash/device",
);
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
if (!codeResult.success) return;
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {

View File

@@ -8,10 +8,10 @@
* authInfo to simulate different users and roles.
*/
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Role } from "@emdash-cms/auth";
import type { RoleLevel } from "@emdash-cms/auth";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { EmDashHandlers } from "../../../src/astro/types.js";

View File

@@ -77,10 +77,7 @@ describe("normalizeMediaValue", () => {
});
it("falls back to external for internal URL when local provider unavailable", async () => {
const result = await normalizeMediaValue(
"/_emdash/api/media/file/01ABC.jpg",
getProvider({}),
);
const result = await normalizeMediaValue("/_emdash/api/media/file/01ABC.jpg", getProvider({}));
expect(result).toEqual({
provider: "external",
id: "",

View File

@@ -35,4 +35,4 @@
"class_name": "AuditWorkflow",
},
],
}
}