Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
/**
* API client for EmDash admin
*
* This file re-exports from the split API modules for backwards compatibility.
* New code should import directly from the specific modules in ./api/
*/
export * from "./api/index.js";

View File

@@ -0,0 +1,96 @@
/**
* API token management client functions
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
// =============================================================================
// Types
// =============================================================================
/** API token info returned from the server */
export interface ApiTokenInfo {
id: string;
name: string;
prefix: string;
scopes: string[];
userId: string;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
}
/** Result from creating a new token */
export interface ApiTokenCreateResult {
/** Raw token — shown once, never stored */
token: string;
/** Token metadata */
info: ApiTokenInfo;
}
/** Input for creating a new token */
export interface CreateApiTokenInput {
name: string;
scopes: string[];
expiresAt?: string;
}
/**
* Scope strings for personal API tokens (wire + UI iteration order).
* Human-readable copy lives in `ApiTokenSettings` (`SCOPE_UI` + Lingui).
*/
export const API_TOKEN_SCOPES = {
ContentRead: "content:read",
ContentWrite: "content:write",
MediaRead: "media:read",
MediaWrite: "media:write",
SchemaRead: "schema:read",
SchemaWrite: "schema:write",
TaxonomiesManage: "taxonomies:manage",
MenusManage: "menus:manage",
SettingsRead: "settings:read",
SettingsManage: "settings:manage",
Admin: "admin",
} as const;
export type ApiTokenScopeValue = (typeof API_TOKEN_SCOPES)[keyof typeof API_TOKEN_SCOPES];
// =============================================================================
// API Functions
// =============================================================================
/**
* Fetch all API tokens for the current user
*/
export async function fetchApiTokens(): Promise<ApiTokenInfo[]> {
const response = await apiFetch(`${API_BASE}/admin/api-tokens`);
const result = await parseApiResponse<{ items: ApiTokenInfo[] }>(
response,
"Failed to fetch API tokens",
);
return result.items;
}
/**
* Create a new API token
*/
export async function createApiToken(input: CreateApiTokenInput): Promise<ApiTokenCreateResult> {
const response = await apiFetch(`${API_BASE}/admin/api-tokens`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<ApiTokenCreateResult>(response, "Failed to create API token");
}
/**
* Revoke (delete) an API token
*/
export async function revokeApiToken(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/api-tokens/${id}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to revoke API token");
}

View File

@@ -0,0 +1,87 @@
import {
API_BASE,
apiFetch,
parseApiResponse,
throwResponseError,
type FindManyResult,
} from "./client.js";
export interface BylineSummary {
id: string;
slug: string;
displayName: string;
bio: string | null;
avatarMediaId: string | null;
websiteUrl: string | null;
userId: string | null;
isGuest: boolean;
createdAt: string;
updatedAt: string;
}
export interface BylineInput {
slug: string;
displayName: string;
bio?: string | null;
avatarMediaId?: string | null;
websiteUrl?: string | null;
userId?: string | null;
isGuest?: boolean;
}
export interface BylineCreditInput {
bylineId: string;
roleLabel?: string | null;
}
export async function fetchBylines(options?: {
search?: string;
isGuest?: boolean;
userId?: string;
cursor?: string;
limit?: number;
}): Promise<FindManyResult<BylineSummary>> {
const params = new URLSearchParams();
if (options?.search) params.set("search", options.search);
if (options?.isGuest !== undefined) params.set("isGuest", String(options.isGuest));
if (options?.userId) params.set("userId", options.userId);
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit) params.set("limit", String(options.limit));
const url = `${API_BASE}/admin/bylines${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<BylineSummary>>(response, "Failed to fetch bylines");
}
export async function fetchByline(id: string): Promise<BylineSummary> {
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}`);
return parseApiResponse<BylineSummary>(response, "Failed to fetch byline");
}
export async function createByline(input: BylineInput): Promise<BylineSummary> {
const response = await apiFetch(`${API_BASE}/admin/bylines`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<BylineSummary>(response, "Failed to create byline");
}
export async function updateByline(
id: string,
input: Partial<BylineInput>,
): Promise<BylineSummary> {
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<BylineSummary>(response, "Failed to update byline");
}
export async function deleteByline(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/bylines/${id}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete byline");
}

View File

@@ -0,0 +1,203 @@
/**
* Base API client configuration and shared types
*/
import type { Element } from "@emdash-cms/blocks";
export const API_BASE = "/_emdash/api";
/**
* Fetch wrapper that adds the X-EmDash-Request CSRF protection header
* to all requests. All API calls should use this instead of raw fetch().
*/
export function apiFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
const headers = new Headers(init?.headers);
headers.set("X-EmDash-Request", "1");
return fetch(input, { ...init, headers });
}
/**
* Throw an error with the message from the API response body if available,
* falling back to a generic message. All API error responses use the shape
* `{ error: { code, message } }`.
*/
export async function throwResponseError(res: Response, fallback: string): Promise<never> {
const body: unknown = await res.json().catch(() => ({}));
let message: string | undefined;
if (typeof body === "object" && body !== null && "error" in body) {
const { error } = body;
if (typeof error === "object" && error !== null && "message" in error) {
const { message: msg } = error;
if (typeof msg === "string") message = msg;
}
}
throw new Error(message || `${fallback}: ${res.statusText}`);
}
/**
* Generic paginated result
*/
export interface FindManyResult<T> {
items: T[];
nextCursor?: string;
}
/**
* Admin manifest describing available collections and plugins
*/
export interface AdminManifest {
version: string;
hash: string;
collections: Record<
string,
{
label: string;
labelSingular: string;
supports: string[];
hasSeo: boolean;
urlPattern?: string;
fields: Record<
string,
{
kind: string;
label?: string;
required?: boolean;
widget?: string;
/**
* For `select` / `multiSelect`: the list of enum choices.
* For `json` fields driven by a plugin `widget`: arbitrary widget config.
*/
options?: Array<{ value: string; label: string }> | Record<string, unknown>;
validation?: Record<string, unknown>;
}
>;
}
>;
plugins: Record<
string,
{
name?: string;
version?: string;
/** Package name for dynamic import (e.g., "@emdash-cms/plugin-audit-log") */
package?: string;
/** Whether the plugin is enabled */
enabled?: boolean;
/**
* How this plugin renders its admin UI:
* - "react": Trusted plugin with React components
* - "blocks": Declarative Block Kit UI via admin route handler
* - "none": No admin UI
*/
adminMode?: "react" | "blocks" | "none";
adminPages?: Array<{
path: string;
label?: string;
icon?: string;
}>;
dashboardWidgets?: Array<{
id: string;
title?: string;
size?: "full" | "half" | "third";
}>;
fieldWidgets?: Array<{
name: string;
label: string;
fieldTypes: string[];
elements?: import("@emdash-cms/blocks").Element[];
}>;
/** Block types for Portable Text editor */
portableTextBlocks?: Array<{
type: string;
label: string;
icon?: string;
description?: string;
placeholder?: string;
fields?: Element[];
category?: string;
}>;
}
>;
/**
* Auth mode for the admin UI. When "passkey", the security settings
* (passkey management, self-signup domains) are shown. When using
* external auth (e.g., "cloudflare-access"), these are hidden since
* authentication is handled externally.
*/
authMode: string;
/**
* Whether self-signup is enabled (at least one allowed domain is active).
* Used by the login page to conditionally show the "Sign up" link.
*/
signupEnabled?: boolean;
/**
* i18n configuration. Present when multiple locales are configured.
*/
i18n?: {
defaultLocale: string;
locales: string[];
};
/**
* Taxonomy definitions for the admin sidebar.
*/
taxonomies: Array<{
name: string;
label: string;
labelSingular?: string;
hierarchical: boolean;
collections: string[];
}>;
/**
* Marketplace registry URL. Present when `marketplace` is configured
* in the EmDash integration. Enables marketplace features in the UI.
*/
marketplace?: string;
/**
* Admin branding overrides for white-labeling.
* Set via the `admin` config in `astro.config.mjs`.
*/
admin?: {
logo?: string;
siteName?: string;
favicon?: string;
};
}
/**
* Parse an API response with the { data: T } envelope.
*
* Handles error responses via throwResponseError, then unwraps the data envelope.
* Replaces both bare `response.json()` and field-unwrap patterns.
*/
export async function parseApiResponse<T>(
response: Response,
fallbackMessage = "Request failed",
): Promise<T> {
if (!response.ok) await throwResponseError(response, fallbackMessage);
const body: { data: T } = await response.json();
return body.data;
}
/**
* Fetch admin manifest
*/
export async function fetchManifest(): Promise<AdminManifest> {
const response = await apiFetch(`${API_BASE}/manifest`);
return parseApiResponse<AdminManifest>(response, "Failed to fetch manifest");
}
/**
* Fetch auth mode (public endpoint — works without authentication).
* Used by the login page to determine which login UI to render.
*/
export async function fetchAuthMode(): Promise<{
authMode: string;
signupEnabled?: boolean;
providers?: Array<{ id: string; label: string }>;
}> {
const response = await apiFetch(`${API_BASE}/auth/mode`);
return parseApiResponse<{
authMode: string;
signupEnabled?: boolean;
providers?: Array<{ id: string; label: string }>;
}>(response, "Failed to fetch auth mode");
}

View File

@@ -0,0 +1,124 @@
/**
* Comment moderation API client
*/
import {
API_BASE,
apiFetch,
parseApiResponse,
throwResponseError,
type FindManyResult,
} from "./client.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type CommentStatus = "pending" | "approved" | "spam" | "trash";
export interface AdminComment {
id: string;
collection: string;
contentId: string;
parentId: string | null;
authorName: string;
authorEmail: string;
authorUserId: string | null;
body: string;
status: CommentStatus;
ipHash: string | null;
userAgent: string | null;
moderationMetadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
export type CommentCounts = Record<CommentStatus, number>;
export type BulkAction = "approve" | "spam" | "trash" | "delete";
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
/**
* Fetch comments for the moderation inbox
*/
export async function fetchComments(options?: {
status?: CommentStatus;
collection?: string;
search?: string;
limit?: number;
cursor?: string;
}): Promise<FindManyResult<AdminComment>> {
const params = new URLSearchParams();
if (options?.status) params.set("status", options.status);
if (options?.collection) params.set("collection", options.collection);
if (options?.search) params.set("search", options.search);
if (options?.limit) params.set("limit", String(options.limit));
if (options?.cursor) params.set("cursor", options.cursor);
const url = `${API_BASE}/admin/comments${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<AdminComment>>(response, "Failed to fetch comments");
}
/**
* Fetch comment status counts for inbox badges
*/
export async function fetchCommentCounts(): Promise<CommentCounts> {
const response = await apiFetch(`${API_BASE}/admin/comments/counts`);
return parseApiResponse<CommentCounts>(response, "Failed to fetch comment counts");
}
/**
* Fetch a single comment by ID
*/
export async function fetchComment(id: string): Promise<AdminComment> {
const response = await apiFetch(`${API_BASE}/admin/comments/${id}`);
return parseApiResponse<AdminComment>(response, "Failed to fetch comment");
}
// ---------------------------------------------------------------------------
// Mutations
// ---------------------------------------------------------------------------
/**
* Update a comment's status
*/
export async function updateCommentStatus(
id: string,
status: CommentStatus,
): Promise<AdminComment> {
const response = await apiFetch(`${API_BASE}/admin/comments/${id}/status`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
return parseApiResponse<AdminComment>(response, "Failed to update comment status");
}
/**
* Hard delete a comment (ADMIN only)
*/
export async function deleteComment(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/comments/${id}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete comment");
}
/**
* Bulk status change or delete
*/
export async function bulkCommentAction(
ids: string[],
action: BulkAction,
): Promise<{ affected: number }> {
const response = await apiFetch(`${API_BASE}/admin/comments/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, action }),
});
return parseApiResponse<{ affected: number }>(response, "Failed to perform bulk action");
}

View File

@@ -0,0 +1,485 @@
/**
* Content CRUD and revision APIs
*/
import type { BylineCreditInput, BylineSummary } from "./bylines.js";
import {
API_BASE,
apiFetch,
parseApiResponse,
throwResponseError,
type FindManyResult,
} from "./client.js";
/**
* Derive draft status from a content item's revision pointers
*/
export function getDraftStatus(
item: ContentItem,
): "unpublished" | "published" | "published_with_changes" {
if (!item.liveRevisionId) return "unpublished";
if (item.draftRevisionId && item.draftRevisionId !== item.liveRevisionId)
return "published_with_changes";
return "published";
}
/** SEO metadata for a content item */
export interface ContentSeo {
title: string | null;
description: string | null;
image: string | null;
canonical: string | null;
noIndex: boolean;
}
export interface ContentItem {
id: string;
type: string;
slug: string | null;
status: string;
locale: string;
translationGroup: string | null;
data: Record<string, unknown>;
authorId: string | null;
primaryBylineId: string | null;
byline?: BylineSummary | null;
bylines?: Array<{
byline: BylineSummary;
sortOrder: number;
roleLabel: string | null;
}>;
createdAt: string;
updatedAt: string;
publishedAt: string | null;
scheduledAt: string | null;
liveRevisionId: string | null;
draftRevisionId: string | null;
seo?: ContentSeo;
}
export interface CreateContentInput {
type: string;
slug?: string;
data: Record<string, unknown>;
status?: string;
bylines?: BylineCreditInput[];
locale?: string;
translationOf?: string;
}
export interface TranslationSummary {
id: string;
locale: string;
slug: string | null;
status: string;
updatedAt: string;
}
export interface TranslationsResponse {
translationGroup: string;
translations: TranslationSummary[];
}
/**
* Fetch translations for a content item
*/
export async function fetchTranslations(
collection: string,
id: string,
): Promise<TranslationsResponse> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/translations`);
return parseApiResponse<TranslationsResponse>(response, "Failed to fetch translations");
}
/** Input for updating SEO fields on content */
export interface ContentSeoInput {
title?: string | null;
description?: string | null;
image?: string | null;
canonical?: string | null;
noIndex?: boolean;
}
export interface UpdateContentInput {
data?: Record<string, unknown>;
slug?: string;
status?: string;
authorId?: string | null;
bylines?: BylineCreditInput[];
/** Skip revision creation (used by autosave) */
skipRevision?: boolean;
seo?: ContentSeoInput;
}
/**
* Trashed content item with deletion timestamp
*/
export interface TrashedContentItem extends ContentItem {
deletedAt: string;
}
/**
* Preview URL response
*/
export interface PreviewUrlResponse {
url: string;
expiresAt: number;
}
/**
* Fetch content list
*/
export async function fetchContentList(
collection: string,
options?: {
cursor?: string;
limit?: number;
status?: string;
locale?: string;
/** Field name to order by, matching the server's whitelist. */
orderBy?: string;
/** Sort direction; defaults to "desc" on the server. */
order?: "asc" | "desc";
},
): Promise<FindManyResult<ContentItem>> {
const params = new URLSearchParams();
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit) params.set("limit", String(options.limit));
if (options?.status) params.set("status", options.status);
if (options?.locale) params.set("locale", options.locale);
if (options?.orderBy) params.set("orderBy", options.orderBy);
if (options?.order) params.set("order", options.order);
const url = `${API_BASE}/content/${collection}${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<ContentItem>>(response, "Failed to fetch content");
}
/**
* Fetch single content item
*/
export async function fetchContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`);
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to fetch content");
return data.item;
}
/**
* Create content
*/
export async function createContent(
collection: string,
input: Omit<CreateContentInput, "type">,
): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
data: input.data,
slug: input.slug,
status: input.status,
bylines: input.bylines,
locale: input.locale,
translationOf: input.translationOf,
}),
});
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to create content");
return data.item;
}
/**
* Update content
*/
export async function updateContent(
collection: string,
id: string,
input: UpdateContentInput,
): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to update content");
return data.item;
}
/**
* Delete content (moves to trash)
*/
export async function deleteContent(collection: string, id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete content");
}
/**
* Fetch trashed content list
*/
export async function fetchTrashedContent(
collection: string,
options?: {
cursor?: string;
limit?: number;
},
): Promise<FindManyResult<TrashedContentItem>> {
const params = new URLSearchParams();
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit) params.set("limit", String(options.limit));
const url = `${API_BASE}/content/${collection}/trash${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<TrashedContentItem>>(
response,
"Failed to fetch trashed content",
);
}
/**
* Restore content from trash
*/
export async function restoreContent(collection: string, id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/restore`, {
method: "POST",
});
if (!response.ok) await throwResponseError(response, "Failed to restore content");
}
/**
* Permanently delete content (cannot be undone)
*/
export async function permanentDeleteContent(collection: string, id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/permanent`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to permanently delete content");
}
/**
* Duplicate content (creates a draft copy)
*/
export async function duplicateContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/duplicate`, {
method: "POST",
});
const data = await parseApiResponse<{ item: ContentItem }>(
response,
"Failed to duplicate content",
);
return data.item;
}
/**
* Schedule content for future publishing
*/
export async function scheduleContent(
collection: string,
id: string,
scheduledAt: string,
): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scheduledAt }),
});
const data = await parseApiResponse<{ item: ContentItem }>(
response,
"Failed to schedule content",
);
return data.item;
}
/**
* Unschedule content (revert to draft)
*/
export async function unscheduleContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/schedule`, {
method: "DELETE",
});
const data = await parseApiResponse<{ item: ContentItem }>(
response,
"Failed to unschedule content",
);
return data.item;
}
/**
* Get a preview URL for content
*
* Returns a signed URL that allows viewing draft content.
* Returns null if the EmDash runtime isn't initialized on the server
* (responds with NOT_CONFIGURED). The preview secret itself no longer
* needs to be set explicitly — it auto-generates on first use.
*/
export async function getPreviewUrl(
collection: string,
id: string,
options?: {
expiresIn?: string;
pathPattern?: string;
},
): Promise<PreviewUrlResponse | null> {
try {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/preview-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(options || {}),
});
if (response.status === 500) {
// Preview not configured — check error code without consuming body for parseApiResponse
const body: unknown = await response.json().catch(() => ({}));
if (
typeof body === "object" &&
body !== null &&
"error" in body &&
typeof body.error === "object" &&
body.error !== null &&
"code" in body.error &&
body.error.code === "NOT_CONFIGURED"
) {
return null;
}
// Some other 500 error
throw new Error("Failed to get preview URL");
}
return parseApiResponse<PreviewUrlResponse>(response, "Failed to get preview URL");
} catch {
// If preview endpoint doesn't exist or fails, return null
return null;
}
}
// =============================================================================
// Publishing (Draft Revisions)
// =============================================================================
/**
* Publish content - promotes current draft to live
*/
export async function publishContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/publish`, {
method: "POST",
});
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to publish content");
return data.item;
}
/**
* Unpublish content - removes from public, preserves draft
*/
export async function unpublishContent(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/unpublish`, {
method: "POST",
});
const data = await parseApiResponse<{ item: ContentItem }>(
response,
"Failed to unpublish content",
);
return data.item;
}
/**
* Discard draft changes - reverts to live version
*/
export async function discardDraft(collection: string, id: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/discard-draft`, {
method: "POST",
});
const data = await parseApiResponse<{ item: ContentItem }>(response, "Failed to discard draft");
return data.item;
}
/**
* Compare live and draft revisions
*/
export async function compareRevisions(
collection: string,
id: string,
): Promise<{
hasChanges: boolean;
live: Record<string, unknown> | null;
draft: Record<string, unknown> | null;
}> {
const response = await apiFetch(`${API_BASE}/content/${collection}/${id}/compare`);
return parseApiResponse<{
hasChanges: boolean;
live: Record<string, unknown> | null;
draft: Record<string, unknown> | null;
}>(response, "Failed to compare revisions");
}
// =============================================================================
// Revision API
// =============================================================================
export interface Revision {
id: string;
collection: string;
entryId: string;
data: Record<string, unknown>;
authorId: string | null;
createdAt: string;
}
export interface RevisionListResponse {
items: Revision[];
total: number;
}
/**
* Fetch revisions for a content item
*/
export async function fetchRevisions(
collection: string,
entryId: string,
options?: { limit?: number },
): Promise<RevisionListResponse> {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", String(options.limit));
const url = `${API_BASE}/content/${collection}/${entryId}/revisions${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<RevisionListResponse>(response, "Failed to fetch revisions");
}
/**
* Get a specific revision
*/
export async function fetchRevision(revisionId: string): Promise<Revision> {
const response = await apiFetch(`${API_BASE}/revisions/${revisionId}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Revision not found: ${revisionId}`);
}
await throwResponseError(response, "Failed to fetch revision");
}
const data = await parseApiResponse<{ item: Revision }>(response, "Failed to fetch revision");
return data.item;
}
/**
* Restore a revision (updates content to this revision's data)
*/
export async function restoreRevision(revisionId: string): Promise<ContentItem> {
const response = await apiFetch(`${API_BASE}/revisions/${revisionId}/restore`, {
method: "POST",
});
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Revision not found: ${revisionId}`);
}
await throwResponseError(response, "Failed to restore revision");
}
const data = await parseApiResponse<{ item: ContentItem }>(
response,
"Failed to restore revision",
);
return data.item;
}

View File

@@ -0,0 +1,30 @@
/**
* Current user query — shared across Shell, Header, Sidebar, and CommandPalette.
*/
import { useQuery } from "@tanstack/react-query";
import { apiFetch, parseApiResponse } from "./client.js";
export interface CurrentUser {
id: string;
email: string;
name?: string;
role: number;
avatarUrl?: string;
isFirstLogin?: boolean;
}
async function fetchCurrentUser(): Promise<CurrentUser> {
const response = await apiFetch("/_emdash/api/auth/me");
return parseApiResponse<CurrentUser>(response, "Failed to fetch user");
}
export function useCurrentUser() {
return useQuery({
queryKey: ["currentUser"],
queryFn: fetchCurrentUser,
staleTime: 5 * 60 * 1000,
retry: false,
});
}

View File

@@ -0,0 +1,39 @@
/**
* Dashboard stats API
*/
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
export interface CollectionStats {
slug: string;
label: string;
total: number;
published: number;
draft: number;
}
export interface RecentItem {
id: string;
collection: string;
collectionLabel: string;
title: string;
slug: string | null;
status: string;
updatedAt: string;
authorId: string | null;
}
export interface DashboardStats {
collections: CollectionStats[];
mediaCount: number;
userCount: number;
recentItems: RecentItem[];
}
/**
* Fetch dashboard statistics
*/
export async function fetchDashboardStats(): Promise<DashboardStats> {
const response = await apiFetch(`${API_BASE}/dashboard`);
return parseApiResponse<DashboardStats>(response, "Failed to fetch dashboard stats");
}

View File

@@ -0,0 +1,41 @@
/**
* Email settings API client functions
*/
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
// =============================================================================
// Types
// =============================================================================
export interface EmailProvider {
pluginId: string;
}
export interface EmailSettings {
available: boolean;
providers: EmailProvider[];
selectedProviderId: string | null;
middleware: {
beforeSend: string[];
afterSend: string[];
};
}
// =============================================================================
// API functions
// =============================================================================
export async function fetchEmailSettings(): Promise<EmailSettings> {
const res = await apiFetch(`${API_BASE}/settings/email`);
return parseApiResponse<EmailSettings>(res, "Failed to fetch email settings");
}
export async function sendTestEmail(to: string): Promise<{ success: boolean; message: string }> {
const res = await apiFetch(`${API_BASE}/settings/email`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to }),
});
return parseApiResponse<{ success: boolean; message: string }>(res, "Failed to send test email");
}

View File

@@ -0,0 +1,465 @@
/**
* WordPress import and source probing APIs
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
// =============================================================================
// WordPress Import API
// =============================================================================
/** Field compatibility status */
export type FieldCompatibility =
| "compatible" // Field exists with compatible type
| "type_mismatch" // Field exists but type differs
| "missing"; // Field doesn't exist
/** Single field definition for import */
export interface ImportFieldDef {
slug: string;
label: string;
type: string;
required: boolean;
}
/** Schema status for a collection */
export interface CollectionSchemaStatus {
exists: boolean;
fieldStatus: Record<
string,
{
status: FieldCompatibility;
existingType?: string;
requiredType: string;
}
>;
canImport: boolean;
reason?: string;
}
/** Post type with full schema info */
export interface PostTypeAnalysis {
name: string;
count: number;
suggestedCollection: string;
requiredFields: ImportFieldDef[];
schemaStatus: CollectionSchemaStatus;
}
/** Individual attachment info for media import */
export interface AttachmentInfo {
id?: number;
title?: string;
url?: string;
filename?: string;
mimeType?: string;
}
/** Navigation menu from WordPress */
export interface NavMenu {
name: string;
slug: string;
count: number;
}
/** Custom taxonomy from WordPress */
export interface CustomTaxonomy {
name: string;
slug: string;
count: number;
hierarchical: boolean;
}
/** Author info from WordPress */
export interface WpAuthorInfo {
id?: number;
login?: string;
email?: string;
displayName?: string;
postCount: number;
}
export interface WxrAnalysis {
site: {
title: string;
url: string;
};
postTypes: PostTypeAnalysis[];
attachments: {
count: number;
items: AttachmentInfo[];
};
categories: number;
tags: number;
authors: WpAuthorInfo[];
customFields: Array<{
key: string;
count: number;
samples: string[];
suggestedField: string;
suggestedType: string;
isInternal: boolean;
}>;
/** Navigation menus found in the export */
navMenus?: NavMenu[];
/** Custom taxonomies found in the export */
customTaxonomies?: CustomTaxonomy[];
}
export interface PrepareRequest {
postTypes: Array<{
name: string;
collection: string;
fields: ImportFieldDef[];
}>;
}
export interface PrepareResult {
success: boolean;
collectionsCreated: string[];
fieldsCreated: Array<{ collection: string; field: string }>;
errors: Array<{ collection: string; error: string }>;
}
/** Author mapping from WP author login to EmDash user ID */
export interface AuthorMapping {
/** WordPress author login */
wpLogin: string;
/** WordPress author display name (for UI) */
wpDisplayName: string;
/** WordPress author email (for matching) */
wpEmail?: string;
/** EmDash user ID to assign (null = leave unassigned) */
emdashUserId: string | null;
/** Number of posts by this author */
postCount: number;
}
export interface ImportConfig {
postTypeMappings: Record<
string,
{
collection: string;
enabled: boolean;
}
>;
skipExisting: boolean;
/** Author mappings (WP author login -> EmDash user ID) */
authorMappings?: Record<string, string | null>;
}
export interface ImportResult {
success: boolean;
imported: number;
skipped: number;
errors: Array<{ title: string; error: string }>;
byCollection: Record<string, number>;
}
/**
* Analyze a WordPress WXR file
*/
export async function analyzeWxr(file: File): Promise<WxrAnalysis> {
const formData = new FormData();
formData.append("file", file);
const response = await apiFetch(`${API_BASE}/import/wordpress/analyze`, {
method: "POST",
body: formData,
});
return parseApiResponse<WxrAnalysis>(response, "Failed to analyze file");
}
/**
* Prepare WordPress import (create collections/fields)
*/
export async function prepareWxrImport(request: PrepareRequest): Promise<PrepareResult> {
const response = await apiFetch(`${API_BASE}/import/wordpress/prepare`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
return parseApiResponse<PrepareResult>(response, "Failed to prepare import");
}
/**
* Execute WordPress import
*/
export async function executeWxrImport(file: File, config: ImportConfig): Promise<ImportResult> {
const formData = new FormData();
formData.append("file", file);
formData.append("config", JSON.stringify(config));
const response = await apiFetch(`${API_BASE}/import/wordpress/execute`, {
method: "POST",
body: formData,
});
return parseApiResponse<ImportResult>(response, "Failed to import");
}
// =============================================================================
// Media Import API
// =============================================================================
export interface MediaImportResult {
imported: Array<{
wpId?: number;
originalUrl: string;
newUrl: string;
mediaId: string;
}>;
failed: Array<{
wpId?: number;
originalUrl: string;
error: string;
}>;
urlMap: Record<string, string>;
}
/** Progress update sent during streaming media import */
export interface MediaImportProgress {
type: "progress";
current: number;
total: number;
filename?: string;
status: "downloading" | "uploading" | "done" | "skipped" | "failed";
error?: string;
}
export interface RewriteUrlsResult {
updated: number;
byCollection: Record<string, number>;
urlsRewritten: number;
errors: Array<{ collection: string; id: string; error: string }>;
}
/**
* Import media from WordPress with streaming progress
*
* @param attachments - Array of attachments to import
* @param onProgress - Callback for progress updates (optional)
* @returns Final import result
*/
export async function importWxrMedia(
attachments: AttachmentInfo[],
onProgress?: (progress: MediaImportProgress) => void,
): Promise<MediaImportResult> {
const response = await apiFetch(`${API_BASE}/import/wordpress/media`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ attachments, stream: !!onProgress }),
});
if (!response.ok) await throwResponseError(response, "Failed to import media");
// If no progress callback, just parse as JSON (non-streaming mode)
// Note: streaming NDJSON responses are excluded from the { data } envelope
if (!onProgress) {
return parseApiResponse<MediaImportResult>(response, "Failed to import media");
}
// Streaming mode: read NDJSON line by line
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder();
let buffer = "";
let result: MediaImportResult | null = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process complete lines
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // Keep incomplete line in buffer
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed: { type?: string; imported?: unknown } = JSON.parse(line);
if (parsed.type === "progress") {
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SSE event data is parsed JSON; discriminated by type === "progress"
onProgress(parsed as MediaImportProgress);
} else if (parsed.type === "result" || parsed.imported) {
// Final result (has type: "result" or is the result object)
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SSE event data is parsed JSON; discriminated by type === "result"
result = parsed as MediaImportResult;
}
} catch {
// Ignore parse errors for incomplete JSON
console.warn("Failed to parse NDJSON line:", line);
}
}
}
// Process any remaining data in buffer
if (buffer.trim()) {
try {
const parsed: { type?: string; imported?: unknown } = JSON.parse(buffer);
if (parsed.type === "result" || parsed.imported) {
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SSE event data is parsed JSON; discriminated by type === "result"
result = parsed as MediaImportResult;
}
} catch {
console.warn("Failed to parse final NDJSON:", buffer);
}
}
if (!result) {
throw new Error("No result received from media import");
}
return result;
}
// =============================================================================
// Import Source Probing
// =============================================================================
/** Capabilities of an import source */
export interface SourceCapabilities {
publicContent: boolean;
privateContent: boolean;
customPostTypes: boolean;
allMeta: boolean;
mediaStream: boolean;
}
/** Auth requirements for import */
export interface SourceAuth {
type: "oauth" | "token" | "password" | "none";
provider?: string;
oauthUrl?: string;
instructions?: string;
}
/** Suggested action after probing */
export type SuggestedAction =
| { type: "proceed" }
| { type: "oauth"; url: string; provider: string }
| { type: "upload"; instructions: string }
| { type: "install-plugin"; instructions: string };
/** Result from probing a single source */
export interface SourceProbeResult {
sourceId: string;
confidence: "definite" | "likely" | "possible";
detected: {
platform: string;
version?: string;
siteTitle?: string;
siteUrl?: string;
};
capabilities: SourceCapabilities;
auth?: SourceAuth;
suggestedAction: SuggestedAction;
preview?: {
posts?: number;
pages?: number;
media?: number;
};
}
/** Combined probe result */
export interface ProbeResult {
url: string;
isWordPress: boolean;
bestMatch: SourceProbeResult | null;
allMatches: SourceProbeResult[];
}
/**
* Probe a URL to detect import source
*/
export async function probeImportUrl(url: string): Promise<ProbeResult> {
const response = await apiFetch(`${API_BASE}/import/probe`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
const data = await parseApiResponse<{ result: ProbeResult }>(response, "Failed to probe URL");
return data.result;
}
/**
* Rewrite URLs in content after media import
*/
export async function rewriteContentUrls(
urlMap: Record<string, string>,
collections?: string[],
): Promise<RewriteUrlsResult> {
const response = await apiFetch(`${API_BASE}/import/wordpress/rewrite-urls`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ urlMap, collections }),
});
return parseApiResponse<RewriteUrlsResult>(response, "Failed to rewrite URLs");
}
// =============================================================================
// WordPress Plugin Direct Import API
// =============================================================================
/** WordPress Plugin analysis result */
export interface WpPluginAnalysis {
sourceId: string;
site: {
title: string;
url: string;
};
postTypes: PostTypeAnalysis[];
attachments: {
count: number;
items: AttachmentInfo[];
};
categories: number;
tags: number;
authors: WpAuthorInfo[];
/** Navigation menus found via the plugin */
navMenus?: NavMenu[];
/** Custom taxonomies found via the plugin */
customTaxonomies?: CustomTaxonomy[];
}
/**
* Analyze a WordPress site with EmDash Exporter plugin
*/
export async function analyzeWpPluginSite(url: string, token: string): Promise<WpPluginAnalysis> {
const response = await apiFetch(`${API_BASE}/import/wordpress-plugin/analyze`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, token }),
});
const data = await parseApiResponse<{ analysis: WpPluginAnalysis }>(
response,
"Failed to analyze WordPress site",
);
return data.analysis;
}
/**
* Execute import from WordPress plugin API
*/
export async function executeWpPluginImport(
url: string,
token: string,
config: ImportConfig,
): Promise<ImportResult> {
const response = await apiFetch(`${API_BASE}/import/wordpress-plugin/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, token, config }),
});
const data = await parseApiResponse<{ result: ImportResult }>(
response,
"Failed to import from WordPress",
);
return data.result;
}

View File

@@ -0,0 +1,356 @@
/**
* API client for EmDash admin
*
* Re-exports all API modules for backwards compatibility.
*/
// Base client and shared types
export {
API_BASE,
apiFetch,
parseApiResponse,
throwResponseError,
type FindManyResult,
type AdminManifest,
fetchManifest,
fetchAuthMode,
} from "./client.js";
// Content CRUD and revisions
export {
type ContentSeo,
type ContentSeoInput,
type ContentItem,
type CreateContentInput,
type UpdateContentInput,
type TrashedContentItem,
type PreviewUrlResponse,
type Revision,
type RevisionListResponse,
type TranslationSummary,
type TranslationsResponse,
getDraftStatus,
fetchContentList,
fetchContent,
fetchTranslations,
createContent,
updateContent,
deleteContent,
fetchTrashedContent,
restoreContent,
permanentDeleteContent,
duplicateContent,
scheduleContent,
unscheduleContent,
getPreviewUrl,
publishContent,
unpublishContent,
discardDraft,
compareRevisions,
fetchRevisions,
fetchRevision,
restoreRevision,
} from "./content.js";
// Media
export {
type MediaItem,
type MediaProviderCapabilities,
type MediaProviderInfo,
type MediaProviderItem,
fetchMediaList,
uploadMedia,
deleteMedia,
updateMedia,
fetchMediaProviders,
fetchProviderMedia,
uploadToProvider,
deleteFromProvider,
} from "./media.js";
// Schema (Content Type Builder)
export {
type FieldType,
type SchemaCollection,
type SchemaField,
type SchemaCollectionWithFields,
type CreateCollectionInput,
type UpdateCollectionInput,
type CreateFieldInput,
type UpdateFieldInput,
type OrphanedTable,
fetchCollections,
fetchCollection,
createCollection,
updateCollection,
deleteCollection,
fetchFields,
createField,
updateField,
deleteField,
reorderFields,
fetchOrphanedTables,
registerOrphanedTable,
} from "./schema.js";
// Plugins
export {
type PluginInfo,
fetchPlugins,
fetchPlugin,
enablePlugin,
disablePlugin,
} from "./plugins.js";
// Settings
export { type SiteSettings, fetchSettings, updateSettings } from "./settings.js";
// Users, passkeys, allowed domains
export {
type UserListItem,
type UserDetail,
type UpdateUserInput,
type PasskeyInfo,
type AllowedDomain,
type CreateAllowedDomainInput,
type UpdateAllowedDomainInput,
type SignupVerifyResult,
type InviteVerifyResult,
fetchUsers,
fetchUser,
updateUser,
sendRecoveryLink,
disableUser,
enableUser,
inviteUser,
validateInviteToken,
fetchPasskeys,
renamePasskey,
deletePasskey,
fetchAllowedDomains,
createAllowedDomain,
updateAllowedDomain,
deleteAllowedDomain,
requestSignup,
verifySignupToken,
completeSignup,
hasAllowedDomains,
} from "./users.js";
// Bylines
export {
type BylineSummary,
type BylineInput,
type BylineCreditInput,
fetchBylines,
fetchByline,
createByline,
updateByline,
deleteByline,
} from "./bylines.js";
// Menus
export {
type Menu,
type MenuItem,
type MenuWithItems,
type CreateMenuInput,
type UpdateMenuInput,
type CreateMenuItemInput,
type UpdateMenuItemInput,
type ReorderMenuItemsInput,
fetchMenus,
fetchMenu,
createMenu,
updateMenu,
deleteMenu,
createMenuItem,
updateMenuItem,
deleteMenuItem,
reorderMenuItems,
} from "./menus.js";
// Widget areas
export {
type WidgetArea,
type Widget,
type WidgetComponent,
type CreateWidgetAreaInput,
type CreateWidgetInput,
type UpdateWidgetInput,
fetchWidgetAreas,
fetchWidgetArea,
createWidgetArea,
deleteWidgetArea,
createWidget,
updateWidget,
deleteWidget,
reorderWidgets,
fetchWidgetComponents,
} from "./widgets.js";
// Sections
export {
type SectionSource,
type Section,
type SectionsResult,
type CreateSectionInput,
type UpdateSectionInput,
type GetSectionsOptions,
fetchSections,
fetchSection,
createSection,
updateSection,
deleteSection,
} from "./sections.js";
// Taxonomies
export {
type TaxonomyTerm,
type TaxonomyDef,
type CreateTaxonomyInput,
type CreateTermInput,
type UpdateTermInput,
fetchTaxonomyDefs,
fetchTaxonomyDef,
fetchTerms,
createTaxonomy,
createTerm,
updateTerm,
deleteTerm,
} from "./taxonomies.js";
// WordPress import
export {
type FieldCompatibility,
type ImportFieldDef,
type CollectionSchemaStatus,
type PostTypeAnalysis,
type AttachmentInfo,
type NavMenu,
type CustomTaxonomy,
type WpAuthorInfo,
type WxrAnalysis,
type PrepareRequest,
type PrepareResult,
type AuthorMapping,
type ImportConfig,
type ImportResult,
type MediaImportResult,
type MediaImportProgress,
type RewriteUrlsResult,
type SourceCapabilities,
type SourceAuth,
type SuggestedAction,
type SourceProbeResult,
type ProbeResult,
type WpPluginAnalysis,
analyzeWxr,
prepareWxrImport,
executeWxrImport,
importWxrMedia,
probeImportUrl,
rewriteContentUrls,
analyzeWpPluginSite,
executeWpPluginImport,
} from "./import.js";
// API Tokens
export {
type ApiTokenInfo,
type ApiTokenCreateResult,
type CreateApiTokenInput,
type ApiTokenScopeValue,
API_TOKEN_SCOPES,
fetchApiTokens,
createApiToken,
revokeApiToken,
} from "./api-tokens.js";
// Comments
export {
type AdminComment,
type CommentStatus,
type CommentCounts,
type BulkAction,
fetchComments,
fetchCommentCounts,
fetchComment,
updateCommentStatus,
deleteComment,
bulkCommentAction,
} from "./comments.js";
// Dashboard
export {
type CollectionStats,
type RecentItem,
type DashboardStats,
fetchDashboardStats,
} from "./dashboard.js";
// Search
export { type SearchEnableResult, setSearchEnabled } from "./search.js";
// Marketplace
export {
type MarketplaceAuthor,
type MarketplaceAuditSummary,
type MarketplaceImageAuditSummary,
type MarketplaceVersion,
type MarketplacePluginSummary,
type MarketplacePluginDetail,
type MarketplaceSearchResult,
type MarketplaceSearchOpts,
type PluginUpdateInfo,
type InstallPluginOpts,
type UpdatePluginOpts,
type UninstallPluginOpts,
searchMarketplace,
fetchMarketplacePlugin,
installMarketplacePlugin,
updateMarketplacePlugin,
uninstallMarketplacePlugin,
checkPluginUpdates,
CAPABILITY_LABELS,
describeCapability,
} from "./marketplace.js";
// Email settings
export {
type EmailProvider,
type EmailSettings,
fetchEmailSettings,
sendTestEmail,
} from "./email-settings.js";
// Theme marketplace
export {
type ThemeAuthor,
type ThemeAuthorDetail,
type ThemeSummary,
type ThemeDetail,
type ThemeSearchResult,
type ThemeSearchOpts,
searchThemes,
fetchTheme,
generatePreviewUrl,
} from "./theme-marketplace.js";
// Redirects
export {
type Redirect,
type NotFoundSummary,
type CreateRedirectInput,
type UpdateRedirectInput,
type RedirectListOptions,
type RedirectListResult,
fetchRedirects,
createRedirect,
updateRedirect,
deleteRedirect,
fetch404Summary,
} from "./redirects.js";
// Current user
export { type CurrentUser, useCurrentUser } from "./current-user.js";

View File

@@ -0,0 +1,247 @@
/**
* Marketplace API client
*
* Calls the site-side proxy endpoints (/_emdash/api/admin/plugins/marketplace/*)
* which forward to the marketplace Worker. This avoids CORS issues since the
* admin UI doesn't need to know the marketplace URL.
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
// ---------------------------------------------------------------------------
// Types — matches the marketplace REST API response shapes
// ---------------------------------------------------------------------------
export interface MarketplaceAuthor {
name: string;
verified: boolean;
}
export interface MarketplaceAuditSummary {
verdict: "pass" | "warn" | "fail";
riskScore: number;
}
export interface MarketplaceImageAuditSummary {
verdict: "pass" | "warn" | "fail";
}
export interface MarketplaceVersion {
version: string;
minEmDashVersion?: string;
bundleSize: number;
changelog?: string;
readme?: string;
screenshotUrls?: string[];
audit?: MarketplaceAuditSummary;
imageAudit?: MarketplaceImageAuditSummary;
publishedAt: string;
}
/** Summary shown in browse cards */
export interface MarketplacePluginSummary {
id: string;
name: string;
description?: string;
author: MarketplaceAuthor;
capabilities: string[];
keywords?: string[];
installCount: number;
iconUrl?: string;
latestVersion?: {
version: string;
audit?: MarketplaceAuditSummary;
imageAudit?: MarketplaceImageAuditSummary;
};
createdAt: string;
updatedAt: string;
}
/** Full detail returned by GET /plugins/:id */
export interface MarketplacePluginDetail extends MarketplacePluginSummary {
license?: string;
repositoryUrl?: string;
homepageUrl?: string;
latestVersion?: MarketplaceVersion;
}
export interface MarketplaceSearchResult {
items: MarketplacePluginSummary[];
nextCursor?: string;
}
export interface MarketplaceSearchOpts {
q?: string;
capability?: string;
sort?: "installs" | "updated" | "created" | "name";
cursor?: string;
limit?: number;
}
/** Update check result per plugin */
export interface PluginUpdateInfo {
pluginId: string;
installed: string;
latest: string;
hasCapabilityChanges: boolean;
}
/** Install request body */
export interface InstallPluginOpts {
version?: string;
}
/** Update request body */
export interface UpdatePluginOpts {
/** User has confirmed new capabilities */
confirmCapabilities?: boolean;
}
/** Uninstall request body */
export interface UninstallPluginOpts {
/** Delete plugin storage data */
deleteData?: boolean;
}
// ---------------------------------------------------------------------------
// API functions — proxy through site endpoints
// ---------------------------------------------------------------------------
const MARKETPLACE_BASE = `${API_BASE}/admin/plugins/marketplace`;
/**
* Search the marketplace catalog.
* Proxied through /_emdash/api/admin/plugins/marketplace
*/
export async function searchMarketplace(
opts: MarketplaceSearchOpts = {},
): Promise<MarketplaceSearchResult> {
const params = new URLSearchParams();
if (opts.q) params.set("q", opts.q);
if (opts.capability) params.set("capability", opts.capability);
if (opts.sort) params.set("sort", opts.sort);
if (opts.cursor) params.set("cursor", opts.cursor);
if (opts.limit) params.set("limit", String(opts.limit));
const qs = params.toString();
const url = `${MARKETPLACE_BASE}${qs ? `?${qs}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<MarketplaceSearchResult>(response, "Marketplace search failed");
}
/**
* Get full plugin detail.
* Proxied through /_emdash/api/admin/plugins/marketplace/:id
*/
export async function fetchMarketplacePlugin(id: string): Promise<MarketplacePluginDetail> {
const response = await apiFetch(`${MARKETPLACE_BASE}/${encodeURIComponent(id)}`);
if (response.status === 404) {
throw new Error(`Plugin "${id}" not found in marketplace`);
}
return parseApiResponse<MarketplacePluginDetail>(response, "Failed to fetch plugin");
}
/**
* Install a plugin from the marketplace.
* POST /_emdash/api/admin/plugins/marketplace/:id/install
*/
export async function installMarketplacePlugin(
id: string,
opts: InstallPluginOpts = {},
): Promise<void> {
const response = await apiFetch(`${MARKETPLACE_BASE}/${encodeURIComponent(id)}/install`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
});
if (!response.ok) await throwResponseError(response, "Failed to install plugin");
}
/**
* Update a marketplace plugin to a newer version.
* POST /_emdash/api/admin/plugins/:id/update
*/
export async function updateMarketplacePlugin(
id: string,
opts: UpdatePluginOpts = {},
): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/plugins/${encodeURIComponent(id)}/update`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
});
if (!response.ok) await throwResponseError(response, "Failed to update plugin");
}
/**
* Uninstall a marketplace plugin.
* POST /_emdash/api/admin/plugins/:id/uninstall
*/
export async function uninstallMarketplacePlugin(
id: string,
opts: UninstallPluginOpts = {},
): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/plugins/${encodeURIComponent(id)}/uninstall`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(opts),
});
if (!response.ok) await throwResponseError(response, "Failed to uninstall plugin");
}
/**
* Check all marketplace plugins for available updates.
* GET /_emdash/api/admin/plugins/updates
*/
export async function checkPluginUpdates(): Promise<PluginUpdateInfo[]> {
const response = await apiFetch(`${API_BASE}/admin/plugins/updates`);
const result = await parseApiResponse<{ items: PluginUpdateInfo[] }>(
response,
"Failed to check for updates",
);
return result.items;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Human-readable labels for plugin capabilities.
*
* Canonical names are the keys; legacy names alias to the same labels so
* old manifests still render meaningful copy until they're republished.
*/
export const CAPABILITY_LABELS: Record<string, string> = {
// Canonical
"content:read": "Read your content",
"content:write": "Create, update, and delete content",
"media:read": "Access your media library",
"media:write": "Upload and manage media",
"users:read": "Read user accounts",
"network:request": "Make network requests",
"network:request:unrestricted": "Make network requests to any host (unrestricted)",
// Legacy aliases (still emitted by older installed manifests)
"read:content": "Read your content",
"write:content": "Create, update, and delete content",
"read:media": "Access your media library",
"write:media": "Upload and manage media",
"read:users": "Read user accounts",
"network:fetch": "Make network requests",
"network:fetch:any": "Make network requests to any host (unrestricted)",
};
/** Capability names that grant scoped network access (legacy + canonical). */
const NETWORK_REQUEST_CAPABILITIES = new Set(["network:request", "network:fetch"]);
/**
* Get a human-readable description for a capability.
* For scoped network capabilities, appends the allowed hosts if provided.
*/
export function describeCapability(capability: string, allowedHosts?: string[]): string {
const base = CAPABILITY_LABELS[capability] ?? capability;
if (NETWORK_REQUEST_CAPABILITIES.has(capability) && allowedHosts && allowedHosts.length > 0) {
return `${base} to: ${allowedHosts.join(", ")}`;
}
return base;
}

View File

@@ -0,0 +1,325 @@
/**
* Media upload, list, delete, and provider APIs
*/
import {
API_BASE,
apiFetch,
parseApiResponse,
throwResponseError,
type FindManyResult,
} from "./client.js";
export interface MediaItem {
id: string;
filename: string;
mimeType: string;
url: string;
/** Storage key for local media (e.g., "01ABC.jpg"). Not present for external URLs. */
storageKey?: string;
size: number;
width?: number;
height?: number;
alt?: string;
caption?: string;
createdAt: string;
/** Provider ID for external media (e.g., "cloudflare-images") */
provider?: string;
/** Provider-specific metadata */
meta?: Record<string, unknown>;
}
/**
* Fetch media list
*/
export async function fetchMediaList(options?: {
cursor?: string;
limit?: number;
mimeType?: string;
}): Promise<FindManyResult<MediaItem>> {
const params = new URLSearchParams();
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit) params.set("limit", String(options.limit));
if (options?.mimeType) params.set("mimeType", options.mimeType);
const url = `${API_BASE}/media${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<MediaItem>>(response, "Failed to fetch media");
}
/**
* Upload URL response from the API
*/
interface UploadUrlResponse {
uploadUrl: string;
method: "PUT";
headers: Record<string, string>;
mediaId: string;
storageKey: string;
expiresAt: string;
}
/**
* Try to get a signed upload URL
* Returns null if signed URLs are not supported (e.g., local storage)
*/
async function getUploadUrl(file: File): Promise<UploadUrlResponse | null> {
try {
const response = await apiFetch(`${API_BASE}/media/upload-url`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: file.name,
contentType: file.type,
size: file.size,
}),
});
if (response.status === 501) {
// Not implemented - storage doesn't support signed URLs
return null;
}
return parseApiResponse<UploadUrlResponse>(response, "Failed to get upload URL");
} catch (error) {
// If the endpoint doesn't exist, fall back to direct upload
if (error instanceof TypeError && error.message.includes("fetch")) {
return null;
}
throw error;
}
}
/**
* Confirm upload after uploading to signed URL
*/
async function confirmUpload(
mediaId: string,
metadata?: { width?: number; height?: number; size?: number },
): Promise<MediaItem> {
const response = await apiFetch(`${API_BASE}/media/${mediaId}/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(metadata || {}),
});
const data = await parseApiResponse<{ item: MediaItem }>(response, "Failed to confirm upload");
return data.item;
}
/**
* Upload directly to signed URL
*/
async function uploadToSignedUrl(file: File, uploadInfo: UploadUrlResponse): Promise<void> {
const response = await fetch(uploadInfo.uploadUrl, {
method: uploadInfo.method,
headers: {
...uploadInfo.headers,
"Content-Type": file.type,
},
body: file,
});
if (!response.ok) await throwResponseError(response, "Failed to upload file");
}
/**
* Get image dimensions from a file
*/
async function getImageDimensions(file: File): Promise<{ width: number; height: number } | null> {
if (!file.type.startsWith("image/")) {
return null;
}
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
resolve(null);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
}
/**
* Upload media file via direct upload (legacy/local storage)
*/
async function uploadMediaDirect(file: File): Promise<MediaItem> {
// Get image dimensions before upload
const dimensions = await getImageDimensions(file);
const formData = new FormData();
formData.append("file", file);
// Send dimensions as form fields
if (dimensions?.width) formData.append("width", String(dimensions.width));
if (dimensions?.height) formData.append("height", String(dimensions.height));
const response = await apiFetch(`${API_BASE}/media`, {
method: "POST",
body: formData,
});
const data = await parseApiResponse<{ item: MediaItem }>(response, "Failed to upload media");
return data.item;
}
/**
* Upload media file
*
* Tries signed URL upload first (for S3/R2 storage), falls back to direct upload
* (for local storage) if signed URLs are not supported.
*/
export async function uploadMedia(file: File): Promise<MediaItem> {
// Try to get a signed upload URL
const uploadInfo = await getUploadUrl(file);
if (!uploadInfo) {
// Signed URLs not supported, use direct upload
return uploadMediaDirect(file);
}
// Upload directly to storage via signed URL
await uploadToSignedUrl(file, uploadInfo);
// Get image dimensions for confirmation
const dimensions = await getImageDimensions(file);
// Confirm the upload
return confirmUpload(uploadInfo.mediaId, {
size: file.size,
width: dimensions?.width,
height: dimensions?.height,
});
}
/**
* Delete media
*/
export async function deleteMedia(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/media/${id}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete media");
}
/**
* Update media metadata (dimensions, alt text, etc.)
*/
export async function updateMedia(
id: string,
input: { alt?: string; caption?: string; width?: number; height?: number },
): Promise<MediaItem> {
const response = await apiFetch(`${API_BASE}/media/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ item: MediaItem }>(response, "Failed to update media");
return data.item;
}
// =============================================================================
// Media Providers API
// =============================================================================
/** Media provider capabilities */
export interface MediaProviderCapabilities {
browse: boolean;
search: boolean;
upload: boolean;
delete: boolean;
}
/** Media provider info from the API */
export interface MediaProviderInfo {
id: string;
name: string;
icon?: string;
capabilities: MediaProviderCapabilities;
}
/** Media item from a provider */
export interface MediaProviderItem {
id: string;
filename: string;
mimeType: string;
size?: number;
width?: number;
height?: number;
alt?: string;
previewUrl?: string;
meta?: Record<string, unknown>;
}
/**
* Fetch all configured media providers
*/
export async function fetchMediaProviders(): Promise<MediaProviderInfo[]> {
const response = await apiFetch(`${API_BASE}/media/providers`);
const data = await parseApiResponse<{ items: MediaProviderInfo[] }>(
response,
"Failed to fetch media providers",
);
return data.items;
}
/**
* Fetch media items from a specific provider
*/
export async function fetchProviderMedia(
providerId: string,
options?: {
cursor?: string;
limit?: number;
query?: string;
mimeType?: string;
},
): Promise<FindManyResult<MediaProviderItem>> {
const params = new URLSearchParams();
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit) params.set("limit", String(options.limit));
if (options?.query) params.set("query", options.query);
if (options?.mimeType) params.set("mimeType", options.mimeType);
const url = `${API_BASE}/media/providers/${providerId}${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<MediaProviderItem>>(
response,
"Failed to fetch provider media",
);
}
/**
* Upload media to a specific provider
*/
export async function uploadToProvider(
providerId: string,
file: File,
alt?: string,
): Promise<MediaProviderItem> {
const formData = new FormData();
formData.append("file", file);
if (alt) formData.append("alt", alt);
const response = await apiFetch(`${API_BASE}/media/providers/${providerId}`, {
method: "POST",
body: formData,
});
const data = await parseApiResponse<{ item: MediaProviderItem }>(
response,
"Failed to upload to provider",
);
return data.item;
}
/**
* Delete media from a specific provider
*/
export async function deleteFromProvider(providerId: string, itemId: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/media/providers/${providerId}/${itemId}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete from provider");
}

View File

@@ -0,0 +1,180 @@
/**
* Menu management APIs
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
export interface Menu {
id: string;
name: string;
label: string;
created_at: string;
updated_at: string;
itemCount?: number;
}
export interface MenuItem {
id: string;
menu_id: string;
parent_id: string | null;
sort_order: number;
type: string;
reference_collection: string | null;
reference_id: string | null;
custom_url: string | null;
label: string;
title_attr: string | null;
target: string | null;
css_classes: string | null;
created_at: string;
}
export interface MenuWithItems extends Menu {
items: MenuItem[];
}
export interface CreateMenuInput {
name: string;
label: string;
}
export interface UpdateMenuInput {
label?: string;
}
export interface CreateMenuItemInput {
type: string;
label: string;
referenceCollection?: string;
referenceId?: string;
customUrl?: string;
target?: string;
titleAttr?: string;
cssClasses?: string;
parentId?: string;
sortOrder?: number;
}
export interface UpdateMenuItemInput {
label?: string;
customUrl?: string;
target?: string;
titleAttr?: string;
cssClasses?: string;
parentId?: string | null;
sortOrder?: number;
}
export interface ReorderMenuItemsInput {
items: Array<{
id: string;
parentId: string | null;
sortOrder: number;
}>;
}
/**
* Fetch all menus
*/
export async function fetchMenus(): Promise<Menu[]> {
const response = await apiFetch(`${API_BASE}/menus`);
return parseApiResponse<Menu[]>(response, "Failed to fetch menus");
}
/**
* Fetch a single menu with items
*/
export async function fetchMenu(name: string): Promise<MenuWithItems> {
const response = await apiFetch(`${API_BASE}/menus/${name}`);
return parseApiResponse<MenuWithItems>(response, "Failed to fetch menu");
}
/**
* Create a menu
*/
export async function createMenu(input: CreateMenuInput): Promise<Menu> {
const response = await apiFetch(`${API_BASE}/menus`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<Menu>(response, "Failed to create menu");
}
/**
* Update a menu
*/
export async function updateMenu(name: string, input: UpdateMenuInput): Promise<Menu> {
const response = await apiFetch(`${API_BASE}/menus/${name}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<Menu>(response, "Failed to update menu");
}
/**
* Delete a menu
*/
export async function deleteMenu(name: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/menus/${name}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete menu");
}
/**
* Create a menu item
*/
export async function createMenuItem(
menuName: string,
input: CreateMenuItemInput,
): Promise<MenuItem> {
const response = await apiFetch(`${API_BASE}/menus/${menuName}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<MenuItem>(response, "Failed to create menu item");
}
/**
* Update a menu item
*/
export async function updateMenuItem(
menuName: string,
itemId: string,
input: UpdateMenuItemInput,
): Promise<MenuItem> {
const response = await apiFetch(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<MenuItem>(response, "Failed to update menu item");
}
/**
* Delete a menu item
*/
export async function deleteMenuItem(menuName: string, itemId: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/menus/${menuName}/items?id=${itemId}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete menu item");
}
/**
* Reorder menu items
*/
export async function reorderMenuItems(
menuName: string,
input: ReorderMenuItemsInput,
): Promise<MenuItem[]> {
const response = await apiFetch(`${API_BASE}/menus/${menuName}/reorder`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<MenuItem[]>(response, "Failed to reorder menu items");
}

View File

@@ -0,0 +1,78 @@
/**
* Plugin management APIs
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
export interface PluginInfo {
id: string;
name: string;
version: string;
package?: string;
enabled: boolean;
status: "installed" | "active" | "inactive";
capabilities: string[];
hasAdminPages: boolean;
hasDashboardWidgets: boolean;
hasHooks: boolean;
installedAt?: string;
activatedAt?: string;
deactivatedAt?: string;
/** Plugin source: 'config' (declared in astro.config) or 'marketplace' */
source?: "config" | "marketplace";
/** Installed marketplace version (set when source = 'marketplace') */
marketplaceVersion?: string;
/** Description of what the plugin does */
description?: string;
/** URL to the plugin icon (marketplace plugins use the icon proxy) */
iconUrl?: string;
}
/**
* Fetch all plugins
*/
export async function fetchPlugins(): Promise<PluginInfo[]> {
const response = await apiFetch(`${API_BASE}/admin/plugins`);
const result = await parseApiResponse<{ items: PluginInfo[] }>(
response,
"Failed to fetch plugins",
);
return result.items;
}
/**
* Fetch a single plugin
*/
export async function fetchPlugin(pluginId: string): Promise<PluginInfo> {
const response = await apiFetch(`${API_BASE}/admin/plugins/${pluginId}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Plugin "${pluginId}" not found`);
}
await throwResponseError(response, "Failed to fetch plugin");
}
const result = await parseApiResponse<{ item: PluginInfo }>(response, "Failed to fetch plugin");
return result.item;
}
/**
* Enable a plugin
*/
export async function enablePlugin(pluginId: string): Promise<PluginInfo> {
const response = await apiFetch(`${API_BASE}/admin/plugins/${pluginId}/enable`, {
method: "POST",
});
const result = await parseApiResponse<{ item: PluginInfo }>(response, "Failed to enable plugin");
return result.item;
}
/**
* Disable a plugin
*/
export async function disablePlugin(pluginId: string): Promise<PluginInfo> {
const response = await apiFetch(`${API_BASE}/admin/plugins/${pluginId}/disable`, {
method: "POST",
});
const result = await parseApiResponse<{ item: PluginInfo }>(response, "Failed to disable plugin");
return result.item;
}

View File

@@ -0,0 +1,127 @@
/**
* Redirects API client
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
export interface Redirect {
id: string;
source: string;
destination: string;
type: number;
isPattern: boolean;
enabled: boolean;
hits: number;
lastHitAt: string | null;
groupName: string | null;
auto: boolean;
createdAt: string;
updatedAt: string;
}
export interface NotFoundSummary {
path: string;
count: number;
lastSeen: string;
topReferrer: string | null;
}
export interface CreateRedirectInput {
source: string;
destination: string;
type?: number;
enabled?: boolean;
groupName?: string | null;
}
export interface UpdateRedirectInput {
source?: string;
destination?: string;
type?: number;
enabled?: boolean;
groupName?: string | null;
}
export interface RedirectListOptions {
cursor?: string;
limit?: number;
search?: string;
group?: string;
enabled?: boolean;
auto?: boolean;
}
export interface RedirectListResult {
items: Redirect[];
nextCursor?: string;
loopRedirectIds?: string[];
}
/**
* List redirects with optional filters
*/
export async function fetchRedirects(options?: RedirectListOptions): Promise<RedirectListResult> {
const params = new URLSearchParams();
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit != null) params.set("limit", String(options.limit));
if (options?.search) params.set("search", options.search);
if (options?.group) params.set("group", options.group);
if (options?.enabled !== undefined) params.set("enabled", String(options.enabled));
if (options?.auto !== undefined) params.set("auto", String(options.auto));
const url = params.toString() ? `${API_BASE}/redirects?${params}` : `${API_BASE}/redirects`;
const response = await apiFetch(url);
return parseApiResponse<RedirectListResult>(response, "Failed to fetch redirects");
}
/**
* Create a redirect
*/
export async function createRedirect(input: CreateRedirectInput): Promise<Redirect> {
const response = await apiFetch(`${API_BASE}/redirects`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<Redirect>(response, "Failed to create redirect");
}
/**
* Update a redirect
*/
export async function updateRedirect(id: string, input: UpdateRedirectInput): Promise<Redirect> {
const response = await apiFetch(`${API_BASE}/redirects/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<Redirect>(response, "Failed to update redirect");
}
/**
* Delete a redirect
*/
export async function deleteRedirect(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/redirects/${encodeURIComponent(id)}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete redirect");
}
/**
* Fetch 404 summary (grouped by path, sorted by count)
*/
export async function fetch404Summary(limit?: number): Promise<NotFoundSummary[]> {
const params = new URLSearchParams();
if (limit != null) params.set("limit", String(limit));
const url = params.toString()
? `${API_BASE}/redirects/404s/summary?${params}`
: `${API_BASE}/redirects/404s/summary`;
const response = await apiFetch(url);
const data = await parseApiResponse<{ items: NotFoundSummary[] }>(
response,
"Failed to fetch 404 summary",
);
return data.items;
}

View File

@@ -0,0 +1,333 @@
/**
* Schema/collection/field management APIs (Content Type Builder)
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
export type FieldType =
| "string"
| "text"
| "url"
| "number"
| "integer"
| "boolean"
| "datetime"
| "select"
| "multiSelect"
| "portableText"
| "image"
| "file"
| "reference"
| "json"
| "slug"
| "repeater";
export interface SchemaCollection {
id: string;
slug: string;
label: string;
labelSingular?: string;
description?: string;
icon?: string;
supports: string[];
source?: string;
urlPattern?: string;
hasSeo: boolean;
commentsEnabled: boolean;
commentsModeration: "all" | "first_time" | "none";
commentsClosedAfterDays: number;
commentsAutoApproveUsers: boolean;
createdAt: string;
updatedAt: string;
}
export interface SchemaField {
id: string;
collectionId: string;
slug: string;
label: string;
type: FieldType;
columnType: string;
required: boolean;
unique: boolean;
searchable: boolean;
defaultValue?: unknown;
validation?: {
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
options?: string[];
};
widget?: string;
options?: Record<string, unknown>;
sortOrder: number;
createdAt: string;
}
export interface SchemaCollectionWithFields extends SchemaCollection {
fields: SchemaField[];
}
export interface CreateCollectionInput {
slug: string;
label: string;
labelSingular?: string;
description?: string;
icon?: string;
supports?: string[];
urlPattern?: string;
hasSeo?: boolean;
}
export interface UpdateCollectionInput {
label?: string;
labelSingular?: string;
description?: string;
icon?: string;
supports?: string[];
urlPattern?: string;
hasSeo?: boolean;
commentsEnabled?: boolean;
commentsModeration?: "all" | "first_time" | "none";
commentsClosedAfterDays?: number;
commentsAutoApproveUsers?: boolean;
}
export interface CreateFieldInput {
slug: string;
label: string;
type: FieldType;
required?: boolean;
unique?: boolean;
searchable?: boolean;
defaultValue?: unknown;
validation?: {
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
options?: string[];
};
widget?: string;
options?: Record<string, unknown>;
}
export interface UpdateFieldInput {
label?: string;
required?: boolean;
unique?: boolean;
searchable?: boolean;
defaultValue?: unknown;
validation?: {
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
options?: string[];
};
widget?: string;
options?: Record<string, unknown>;
sortOrder?: number;
}
/**
* Fetch all collections
*/
export async function fetchCollections(): Promise<SchemaCollection[]> {
const response = await apiFetch(`${API_BASE}/schema/collections`);
const data = await parseApiResponse<{ items: SchemaCollection[] }>(
response,
"Failed to fetch collections",
);
return data.items;
}
/**
* Fetch a single collection with fields
*/
export async function fetchCollection(
slug: string,
includeFields = true,
): Promise<SchemaCollectionWithFields> {
const url = includeFields
? `${API_BASE}/schema/collections/${slug}?includeFields=true`
: `${API_BASE}/schema/collections/${slug}`;
const response = await apiFetch(url);
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Collection "${slug}" not found`);
}
await throwResponseError(response, "Failed to fetch collection");
}
const data = await parseApiResponse<{ item: SchemaCollectionWithFields }>(
response,
"Failed to fetch collection",
);
return data.item;
}
/**
* Create a collection
*/
export async function createCollection(input: CreateCollectionInput): Promise<SchemaCollection> {
const response = await apiFetch(`${API_BASE}/schema/collections`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ item: SchemaCollection }>(
response,
"Failed to create collection",
);
return data.item;
}
/**
* Update a collection
*/
export async function updateCollection(
slug: string,
input: UpdateCollectionInput,
): Promise<SchemaCollection> {
const response = await apiFetch(`${API_BASE}/schema/collections/${slug}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ item: SchemaCollection }>(
response,
"Failed to update collection",
);
return data.item;
}
/**
* Delete a collection
*/
export async function deleteCollection(slug: string, force = false): Promise<void> {
const url = force
? `${API_BASE}/schema/collections/${slug}?force=true`
: `${API_BASE}/schema/collections/${slug}`;
const response = await apiFetch(url, { method: "DELETE" });
if (!response.ok) await throwResponseError(response, "Failed to delete collection");
}
/**
* Fetch fields for a collection
*/
export async function fetchFields(collectionSlug: string): Promise<SchemaField[]> {
const response = await apiFetch(`${API_BASE}/schema/collections/${collectionSlug}/fields`);
const data = await parseApiResponse<{ items: SchemaField[] }>(response, "Failed to fetch fields");
return data.items;
}
/**
* Create a field
*/
export async function createField(
collectionSlug: string,
input: CreateFieldInput,
): Promise<SchemaField> {
const response = await apiFetch(`${API_BASE}/schema/collections/${collectionSlug}/fields`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ item: SchemaField }>(response, "Failed to create field");
return data.item;
}
/**
* Update a field
*/
export async function updateField(
collectionSlug: string,
fieldSlug: string,
input: UpdateFieldInput,
): Promise<SchemaField> {
const response = await apiFetch(
`${API_BASE}/schema/collections/${collectionSlug}/fields/${fieldSlug}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
);
const data = await parseApiResponse<{ item: SchemaField }>(response, "Failed to update field");
return data.item;
}
/**
* Delete a field
*/
export async function deleteField(collectionSlug: string, fieldSlug: string): Promise<void> {
const response = await apiFetch(
`${API_BASE}/schema/collections/${collectionSlug}/fields/${fieldSlug}`,
{ method: "DELETE" },
);
if (!response.ok) await throwResponseError(response, "Failed to delete field");
}
/**
* Reorder fields
*/
export async function reorderFields(collectionSlug: string, fieldSlugs: string[]): Promise<void> {
const response = await apiFetch(
`${API_BASE}/schema/collections/${collectionSlug}/fields/reorder`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fieldSlugs }),
},
);
if (!response.ok) await throwResponseError(response, "Failed to reorder fields");
}
// ============================================
// Orphaned Tables
// ============================================
export interface OrphanedTable {
slug: string;
tableName: string;
rowCount: number;
}
/**
* Fetch orphaned content tables
*/
export async function fetchOrphanedTables(): Promise<OrphanedTable[]> {
const response = await apiFetch(`${API_BASE}/schema/orphans`);
const data = await parseApiResponse<{ items: OrphanedTable[] }>(
response,
"Failed to fetch orphaned tables",
);
return data.items;
}
/**
* Register an orphaned table as a collection
*/
export async function registerOrphanedTable(
slug: string,
options?: {
label?: string;
labelSingular?: string;
description?: string;
},
): Promise<SchemaCollection> {
const response = await apiFetch(`${API_BASE}/schema/orphans/${slug}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(options || {}),
});
const data = await parseApiResponse<{ item: SchemaCollection }>(
response,
"Failed to register orphaned table",
);
return data.item;
}

View File

@@ -0,0 +1,31 @@
/**
* Search enable/disable APIs
*/
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
export interface SearchEnableResult {
success: boolean;
collection: string;
enabled: boolean;
indexed?: number;
}
/**
* Enable or disable search for a collection
*/
export async function setSearchEnabled(
collection: string,
enabled: boolean,
weights?: Record<string, number>,
): Promise<SearchEnableResult> {
const response = await apiFetch(`${API_BASE}/search/enable`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ collection, enabled, weights }),
});
return parseApiResponse<SearchEnableResult>(
response,
`Failed to ${enabled ? "enable" : "disable"} search`,
);
}

View File

@@ -0,0 +1,108 @@
/**
* Sections API (reusable content blocks)
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
export type SectionSource = "theme" | "user" | "import";
export interface Section {
id: string;
slug: string;
title: string;
description?: string;
keywords: string[];
content: unknown[]; // Portable Text
previewUrl?: string;
source: SectionSource;
themeId?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateSectionInput {
slug: string;
title: string;
description?: string;
keywords?: string[];
content: unknown[];
previewMediaId?: string;
}
export interface UpdateSectionInput {
slug?: string;
title?: string;
description?: string;
keywords?: string[];
content?: unknown[];
previewMediaId?: string | null;
}
export interface GetSectionsOptions {
source?: SectionSource;
search?: string;
limit?: number;
cursor?: string;
}
export interface SectionsResult {
items: Section[];
nextCursor?: string;
}
/**
* Fetch all sections
*/
export async function fetchSections(options?: GetSectionsOptions): Promise<SectionsResult> {
const params = new URLSearchParams();
if (options?.source) params.set("source", options.source);
if (options?.search) params.set("search", options.search);
if (options?.limit) params.set("limit", String(options.limit));
if (options?.cursor) params.set("cursor", options.cursor);
const url = params.toString() ? `${API_BASE}/sections?${params}` : `${API_BASE}/sections`;
const response = await apiFetch(url);
return parseApiResponse<SectionsResult>(response, "Failed to fetch sections");
}
/**
* Fetch a single section by slug
*/
export async function fetchSection(slug: string): Promise<Section> {
const response = await apiFetch(`${API_BASE}/sections/${slug}`);
return parseApiResponse<Section>(response, "Failed to fetch section");
}
/**
* Create a section
*/
export async function createSection(input: CreateSectionInput): Promise<Section> {
const response = await apiFetch(`${API_BASE}/sections`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<Section>(response, "Failed to create section");
}
/**
* Update a section
*/
export async function updateSection(slug: string, input: UpdateSectionInput): Promise<Section> {
const response = await apiFetch(`${API_BASE}/sections/${slug}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<Section>(response, "Failed to update section");
}
/**
* Delete a section
*/
export async function deleteSection(slug: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/sections/${slug}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete section");
}

View File

@@ -0,0 +1,62 @@
/**
* Site settings APIs
*/
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
export interface SiteSettings {
// Identity
title: string;
tagline?: string;
logo?: { mediaId: string; alt?: string; url?: string };
favicon?: { mediaId: string; url?: string };
// URLs
url?: string;
// Display
postsPerPage: number;
dateFormat: string;
timezone: string;
// Social
social?: {
twitter?: string;
github?: string;
facebook?: string;
instagram?: string;
linkedin?: string;
youtube?: string;
};
// SEO
seo?: {
titleSeparator?: string;
defaultOgImage?: { mediaId: string; alt?: string; url?: string };
robotsTxt?: string;
googleVerification?: string;
bingVerification?: string;
};
}
/**
* Fetch site settings
*/
export async function fetchSettings(): Promise<Partial<SiteSettings>> {
const response = await apiFetch(`${API_BASE}/settings`);
return parseApiResponse<Partial<SiteSettings>>(response, "Failed to fetch settings");
}
/**
* Update site settings
*/
export async function updateSettings(
settings: Partial<SiteSettings>,
): Promise<Partial<SiteSettings>> {
const response = await apiFetch(`${API_BASE}/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
});
return parseApiResponse<Partial<SiteSettings>>(response, "Failed to update settings");
}

View File

@@ -0,0 +1,134 @@
/**
* Taxonomies API (categories, tags, custom taxonomies)
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
export interface TaxonomyTerm {
id: string;
name: string;
slug: string;
label: string;
parentId?: string;
description?: string;
children: TaxonomyTerm[];
count?: number;
}
export interface TaxonomyDef {
id: string;
name: string;
label: string;
labelSingular?: string;
hierarchical: boolean;
collections: string[];
}
export interface CreateTaxonomyInput {
name: string;
label: string;
hierarchical?: boolean;
collections?: string[];
}
export interface CreateTermInput {
slug: string;
label: string;
parentId?: string;
description?: string;
}
export interface UpdateTermInput {
slug?: string;
label?: string;
parentId?: string;
description?: string;
}
/**
* Fetch all taxonomy definitions
*/
export async function fetchTaxonomyDefs(): Promise<TaxonomyDef[]> {
const response = await apiFetch(`${API_BASE}/taxonomies`);
const data = await parseApiResponse<{ taxonomies: TaxonomyDef[] }>(
response,
"Failed to fetch taxonomies",
);
return data.taxonomies;
}
/**
* Fetch taxonomy definition by name
*/
export async function fetchTaxonomyDef(name: string): Promise<TaxonomyDef | null> {
const defs = await fetchTaxonomyDefs();
return defs.find((t) => t.name === name) || null;
}
/**
* Create a custom taxonomy definition
*/
export async function createTaxonomy(input: CreateTaxonomyInput): Promise<TaxonomyDef> {
const response = await apiFetch(`${API_BASE}/taxonomies`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ taxonomy: TaxonomyDef }>(
response,
"Failed to create taxonomy",
);
return data.taxonomy;
}
/**
* Fetch terms for a taxonomy
*/
export async function fetchTerms(taxonomyName: string): Promise<TaxonomyTerm[]> {
const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms`);
const data = await parseApiResponse<{ terms: TaxonomyTerm[] }>(response, "Failed to fetch terms");
return data.terms;
}
/**
* Create a term
*/
export async function createTerm(
taxonomyName: string,
input: CreateTermInput,
): Promise<TaxonomyTerm> {
const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ term: TaxonomyTerm }>(response, "Failed to create term");
return data.term;
}
/**
* Update a term
*/
export async function updateTerm(
taxonomyName: string,
slug: string,
input: UpdateTermInput,
): Promise<TaxonomyTerm> {
const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms/${slug}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ term: TaxonomyTerm }>(response, "Failed to update term");
return data.term;
}
/**
* Delete a term
*/
export async function deleteTerm(taxonomyName: string, slug: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/taxonomies/${taxonomyName}/terms/${slug}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete term");
}

View File

@@ -0,0 +1,114 @@
/**
* Theme Marketplace API client
*
* Calls the site-side proxy endpoints (/_emdash/api/admin/themes/marketplace/*)
* which forward to the marketplace Worker. The preview signing endpoint
* is local (/_emdash/api/themes/preview).
*/
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
// ---------------------------------------------------------------------------
// Types — matches the marketplace REST API response shapes
// ---------------------------------------------------------------------------
export interface ThemeAuthor {
name: string;
verified: boolean;
avatarUrl: string | null;
}
export interface ThemeAuthorDetail extends ThemeAuthor {
id: string;
}
/** Summary shown in browse cards */
export interface ThemeSummary {
id: string;
name: string;
description: string | null;
author: ThemeAuthor;
keywords: string[];
previewUrl: string;
demoUrl: string | null;
hasThumbnail: boolean;
thumbnailUrl: string | null;
createdAt: string;
updatedAt: string;
}
/** Full detail returned by GET /themes/:id */
export interface ThemeDetail extends Omit<ThemeSummary, "author"> {
author: ThemeAuthorDetail;
repositoryUrl: string | null;
homepageUrl: string | null;
license: string | null;
screenshotCount: number;
screenshotUrls: string[];
}
export interface ThemeSearchResult {
items: ThemeSummary[];
nextCursor?: string;
}
export interface ThemeSearchOpts {
q?: string;
keyword?: string;
sort?: "name" | "created" | "updated";
cursor?: string;
limit?: number;
}
// ---------------------------------------------------------------------------
// API functions
// ---------------------------------------------------------------------------
const THEME_MARKETPLACE_BASE = `${API_BASE}/admin/themes/marketplace`;
/**
* Search theme listings.
* Proxied through /_emdash/api/admin/themes/marketplace
*/
export async function searchThemes(opts: ThemeSearchOpts = {}): Promise<ThemeSearchResult> {
const params = new URLSearchParams();
if (opts.q) params.set("q", opts.q);
if (opts.keyword) params.set("keyword", opts.keyword);
if (opts.sort) params.set("sort", opts.sort);
if (opts.cursor) params.set("cursor", opts.cursor);
if (opts.limit) params.set("limit", String(opts.limit));
const qs = params.toString();
const url = `${THEME_MARKETPLACE_BASE}${qs ? `?${qs}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<ThemeSearchResult>(response, "Theme search failed");
}
/**
* Get full theme detail.
* Proxied through /_emdash/api/admin/themes/marketplace/:id
*/
export async function fetchTheme(id: string): Promise<ThemeDetail> {
const response = await apiFetch(`${THEME_MARKETPLACE_BASE}/${encodeURIComponent(id)}`);
if (response.status === 404) {
throw new Error(`Theme "${id}" not found`);
}
return parseApiResponse<ThemeDetail>(response, "Failed to fetch theme");
}
/**
* Generate a signed preview URL for the "Try with my data" flow.
* POST /_emdash/api/themes/preview (local, not proxied)
*/
export async function generatePreviewUrl(previewUrl: string): Promise<string> {
const response = await apiFetch(`${API_BASE}/themes/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ previewUrl }),
});
const result = await parseApiResponse<{ url: string }>(
response,
"Failed to generate preview URL",
);
return result.url;
}

View File

@@ -0,0 +1,446 @@
/**
* User management, passkeys, and allowed domains APIs
*/
import {
API_BASE,
apiFetch,
parseApiResponse,
throwResponseError,
type FindManyResult,
} from "./client.js";
// =============================================================================
// User Management API
// =============================================================================
/** User list item with computed fields */
export interface UserListItem {
id: string;
email: string;
name: string | null;
avatarUrl: string | null;
role: number;
emailVerified: boolean;
disabled: boolean;
createdAt: string;
updatedAt: string;
lastLogin: string | null;
credentialCount: number;
oauthProviders: string[];
}
/** User detail with credentials and OAuth accounts */
export interface UserDetail extends UserListItem {
credentials: Array<{
id: string;
name: string | null;
deviceType: string;
createdAt: string;
lastUsedAt: string;
}>;
oauthAccounts: Array<{
provider: string;
createdAt: string;
}>;
}
/** User update input */
export interface UpdateUserInput {
name?: string;
email?: string;
role?: number;
}
/**
* Fetch users with search, filter, and pagination
*/
export async function fetchUsers(options?: {
search?: string;
role?: number;
cursor?: string;
limit?: number;
}): Promise<FindManyResult<UserListItem>> {
const params = new URLSearchParams();
if (options?.search) params.set("search", options.search);
if (options?.role !== undefined) params.set("role", String(options.role));
if (options?.cursor) params.set("cursor", options.cursor);
if (options?.limit) params.set("limit", String(options.limit));
const url = `${API_BASE}/admin/users${params.toString() ? `?${params}` : ""}`;
const response = await apiFetch(url);
return parseApiResponse<FindManyResult<UserListItem>>(response, "Failed to fetch users");
}
/**
* Fetch a single user with details
*/
export async function fetchUser(id: string): Promise<UserDetail> {
const response = await apiFetch(`${API_BASE}/admin/users/${id}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error(`User not found: ${id}`);
}
await throwResponseError(response, "Failed to fetch user");
}
const data = await parseApiResponse<{ item: UserDetail }>(response, "Failed to fetch user");
return data.item;
}
/**
* Update a user
*/
export async function updateUser(id: string, input: UpdateUserInput): Promise<UserDetail> {
const response = await apiFetch(`${API_BASE}/admin/users/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ item: UserDetail }>(response, "Failed to update user");
return data.item;
}
/**
* Disable a user
*/
export async function disableUser(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/users/${id}/disable`, {
method: "POST",
});
if (!response.ok) await throwResponseError(response, "Failed to disable user");
}
/**
* Send a recovery magic link to a user
*/
export async function sendRecoveryLink(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/users/${id}/send-recovery`, {
method: "POST",
});
if (!response.ok) await throwResponseError(response, "Failed to send recovery link");
}
/**
* Enable a user
*/
export async function enableUser(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/admin/users/${id}/enable`, {
method: "POST",
});
if (!response.ok) await throwResponseError(response, "Failed to enable user");
}
/** Invite response -- includes inviteUrl when no email provider is configured */
export interface InviteResult {
success: true;
message: string;
/** Present when no email provider is configured (copy-link fallback) */
inviteUrl?: string;
}
/**
* Invite a new user
*
* Uses the existing /auth/invite endpoint.
* When no email provider is configured, the response includes
* an `inviteUrl` for manual sharing.
*/
export async function inviteUser(email: string, role?: number): Promise<InviteResult> {
const response = await apiFetch(`${API_BASE}/auth/invite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, role }),
});
return parseApiResponse<InviteResult>(response, "Failed to invite user");
}
// =============================================================================
// Invite Accept API (for invited users completing registration)
// =============================================================================
/** Invite token verification result */
export interface InviteVerifyResult {
email: string;
role: number;
roleName: string;
}
/**
* Validate an invite token and return the invite data.
*
* Uses custom error handling to preserve error codes for the UI.
*/
export async function validateInviteToken(token: string): Promise<InviteVerifyResult> {
const response = await apiFetch(
`${API_BASE}/auth/invite/accept?token=${encodeURIComponent(token)}`,
);
if (!response.ok) {
const errorData: unknown = await response.json().catch(() => ({}));
let message = `Invite validation failed: ${response.statusText}`;
let code: string | undefined;
if (typeof errorData === "object" && errorData !== null && "error" in errorData) {
const err = errorData.error;
if (typeof err === "object" && err !== null) {
if ("message" in err && typeof err.message === "string") message = err.message;
if ("code" in err && typeof err.code === "string") code = err.code;
}
}
const error: Error & { code?: string } = new Error(message);
error.code = code;
throw error;
}
return parseApiResponse<InviteVerifyResult>(response, "Invite validation failed");
}
// =============================================================================
// Passkey Management API
// =============================================================================
/**
* Passkey info returned from API
*/
export interface PasskeyInfo {
id: string;
name: string | null;
deviceType: "singleDevice" | "multiDevice";
backedUp: boolean;
createdAt: string;
lastUsedAt: string;
}
/**
* List all passkeys for the current user
*/
export async function fetchPasskeys(): Promise<PasskeyInfo[]> {
const response = await apiFetch(`${API_BASE}/auth/passkey`);
const data = await parseApiResponse<{ items: PasskeyInfo[] }>(
response,
"Failed to fetch passkeys",
);
return data.items;
}
/**
* Rename a passkey
*/
export async function renamePasskey(id: string, name: string): Promise<PasskeyInfo> {
const response = await apiFetch(`${API_BASE}/auth/passkey/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const data = await parseApiResponse<{ passkey: PasskeyInfo }>(
response,
"Failed to rename passkey",
);
return data.passkey;
}
/**
* Delete a passkey
*/
export async function deletePasskey(id: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/auth/passkey/${id}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete passkey");
}
// =============================================================================
// Allowed Domains API (Self-Signup)
// =============================================================================
/** Allowed domain for self-signup */
export interface AllowedDomain {
domain: string;
defaultRole: number;
roleName: string;
enabled: boolean;
createdAt: string;
}
/** Create allowed domain input */
export interface CreateAllowedDomainInput {
domain: string;
defaultRole: number;
}
/** Update allowed domain input */
export interface UpdateAllowedDomainInput {
enabled?: boolean;
defaultRole?: number;
}
/**
* Fetch all allowed domains
*/
export async function fetchAllowedDomains(): Promise<AllowedDomain[]> {
const response = await apiFetch(`${API_BASE}/admin/allowed-domains`);
const data = await parseApiResponse<{ domains: AllowedDomain[] }>(
response,
"Failed to fetch allowed domains",
);
return data.domains;
}
/**
* Create an allowed domain
*/
export async function createAllowedDomain(input: CreateAllowedDomainInput): Promise<AllowedDomain> {
const response = await apiFetch(`${API_BASE}/admin/allowed-domains`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
const data = await parseApiResponse<{ domain: AllowedDomain }>(
response,
"Failed to create allowed domain",
);
return data.domain;
}
/**
* Update an allowed domain
*/
export async function updateAllowedDomain(
domain: string,
input: UpdateAllowedDomainInput,
): Promise<AllowedDomain> {
const response = await apiFetch(
`${API_BASE}/admin/allowed-domains/${encodeURIComponent(domain)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
},
);
const data = await parseApiResponse<{ domain: AllowedDomain }>(
response,
"Failed to update allowed domain",
);
return data.domain;
}
/**
* Delete an allowed domain
*/
export async function deleteAllowedDomain(domain: string): Promise<void> {
const response = await apiFetch(
`${API_BASE}/admin/allowed-domains/${encodeURIComponent(domain)}`,
{
method: "DELETE",
},
);
if (!response.ok) await throwResponseError(response, "Failed to delete allowed domain");
}
// =============================================================================
// Self-Signup API
// =============================================================================
/** Signup verification result */
export interface SignupVerifyResult {
email: string;
role: number;
roleName: string;
}
/**
* Request signup - send verification email
* Always returns success to prevent enumeration
*/
export async function requestSignup(email: string): Promise<{ success: true; message: string }> {
const response = await apiFetch(`${API_BASE}/auth/signup/request`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
return parseApiResponse<{ success: true; message: string }>(response, "Signup request failed");
}
/**
* Verify signup token
*
* Uses custom error handling to preserve error codes for the UI.
*/
export async function verifySignupToken(token: string): Promise<SignupVerifyResult> {
const response = await apiFetch(
`${API_BASE}/auth/signup/verify?token=${encodeURIComponent(token)}`,
);
if (!response.ok) {
const errorData: unknown = await response.json().catch(() => ({}));
let message = `Token verification failed: ${response.statusText}`;
let code: string | undefined;
if (typeof errorData === "object" && errorData !== null && "error" in errorData) {
const err = errorData.error;
if (typeof err === "object" && err !== null) {
if ("message" in err && typeof err.message === "string") message = err.message;
if ("code" in err && typeof err.code === "string") code = err.code;
}
}
const error: Error & { code?: string } = new Error(message);
error.code = code;
throw error;
}
return parseApiResponse<SignupVerifyResult>(response, "Token verification failed");
}
/**
* Complete signup with passkey registration
*
* Uses custom error handling to preserve error codes for the UI.
*/
export async function completeSignup(
token: string,
credential: unknown,
name?: string,
): Promise<{
success: true;
user: { id: string; email: string; name: string | null; role: number };
}> {
const response = await apiFetch(`${API_BASE}/auth/signup/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, credential, name }),
});
if (!response.ok) {
const errorData: unknown = await response.json().catch(() => ({}));
let message = `Signup completion failed: ${response.statusText}`;
let code: string | undefined;
if (typeof errorData === "object" && errorData !== null && "error" in errorData) {
const err = errorData.error;
if (typeof err === "object" && err !== null) {
if ("message" in err && typeof err.message === "string") message = err.message;
if ("code" in err && typeof err.code === "string") code = err.code;
}
}
const error: Error & { code?: string } = new Error(message);
error.code = code;
throw error;
}
return parseApiResponse<{
success: true;
user: { id: string; email: string; name: string | null; role: number };
}>(response, "Signup completion failed");
}
/**
* Check if any allowed domains exist (for showing signup link)
*/
export async function hasAllowedDomains(): Promise<boolean> {
try {
const domains = await fetchAllowedDomains();
return domains.some((d) => d.enabled);
} catch {
// If we can't fetch (e.g., not logged in), assume no domains
return false;
}
}

View File

@@ -0,0 +1,168 @@
/**
* Widget areas APIs
*/
import { API_BASE, apiFetch, parseApiResponse, throwResponseError } from "./client.js";
export interface WidgetArea {
id: string;
name: string;
label: string;
description?: string;
widgets?: Widget[];
widgetCount?: number;
}
export interface Widget {
id: string;
type: "content" | "menu" | "component";
title?: string;
content?: unknown[]; // Portable Text
menuName?: string;
componentId?: string;
componentProps?: Record<string, unknown>;
sort_order?: number;
}
export interface WidgetComponent {
id: string;
label: string;
description?: string;
props: Record<
string,
{
type: "string" | "number" | "boolean" | "select";
label: string;
default?: unknown;
options?: Array<{ value: string; label: string }>;
}
>;
}
export interface CreateWidgetAreaInput {
name: string;
label: string;
description?: string;
}
export interface CreateWidgetInput {
type: "content" | "menu" | "component";
title?: string;
content?: unknown[];
menuName?: string;
componentId?: string;
componentProps?: Record<string, unknown>;
}
export interface UpdateWidgetInput {
type?: "content" | "menu" | "component";
title?: string;
content?: unknown[];
menuName?: string;
componentId?: string;
componentProps?: Record<string, unknown>;
}
/**
* Fetch all widget areas
*/
export async function fetchWidgetAreas(): Promise<WidgetArea[]> {
const response = await apiFetch(`${API_BASE}/widget-areas`);
const data = await parseApiResponse<{ items: WidgetArea[] }>(
response,
"Failed to fetch widget areas",
);
return data.items;
}
/**
* Fetch a single widget area by name
*/
export async function fetchWidgetArea(name: string): Promise<WidgetArea> {
const response = await apiFetch(`${API_BASE}/widget-areas/${name}`);
return parseApiResponse<WidgetArea>(response, "Failed to fetch widget area");
}
/**
* Create a widget area
*/
export async function createWidgetArea(input: CreateWidgetAreaInput): Promise<WidgetArea> {
const response = await apiFetch(`${API_BASE}/widget-areas`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<WidgetArea>(response, "Failed to create widget area");
}
/**
* Delete a widget area
*/
export async function deleteWidgetArea(name: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/widget-areas/${name}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete widget area");
}
/**
* Add a widget to an area
*/
export async function createWidget(areaName: string, input: CreateWidgetInput): Promise<Widget> {
const response = await apiFetch(`${API_BASE}/widget-areas/${areaName}/widgets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<Widget>(response, "Failed to create widget");
}
/**
* Update a widget
*/
export async function updateWidget(
areaName: string,
widgetId: string,
input: UpdateWidgetInput,
): Promise<Widget> {
const response = await apiFetch(`${API_BASE}/widget-areas/${areaName}/widgets/${widgetId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return parseApiResponse<Widget>(response, "Failed to update widget");
}
/**
* Delete a widget
*/
export async function deleteWidget(areaName: string, widgetId: string): Promise<void> {
const response = await apiFetch(`${API_BASE}/widget-areas/${areaName}/widgets/${widgetId}`, {
method: "DELETE",
});
if (!response.ok) await throwResponseError(response, "Failed to delete widget");
}
/**
* Reorder widgets in an area
*/
export async function reorderWidgets(areaName: string, widgetIds: string[]): Promise<void> {
const response = await apiFetch(`${API_BASE}/widget-areas/${areaName}/reorder`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ widgetIds }),
});
if (!response.ok) await throwResponseError(response, "Failed to reorder widgets");
}
/**
* Fetch available widget components
*/
export async function fetchWidgetComponents(): Promise<WidgetComponent[]> {
const response = await apiFetch(`${API_BASE}/widget-components`);
const data = await parseApiResponse<{ items: WidgetComponent[] }>(
response,
"Failed to fetch widget components",
);
return data.items;
}

View File

@@ -0,0 +1,62 @@
/**
* Auth Provider Context
*
* Provides pluggable auth provider UI components (LoginButton, LoginForm, SetupStep)
* to the admin UI via React context. Auth providers are registered in astro.config.ts
* and their admin components are bundled via the virtual:emdash/auth-providers module.
*/
import * as React from "react";
import { createContext, useContext } from "react";
/** Shape of a single auth provider's admin exports */
export interface AuthProviderModule {
id: string;
label: string;
/** Compact button for the login page (icon + label) */
LoginButton?: React.ComponentType;
/** Full form if the provider needs custom input (e.g., handle field) */
LoginForm?: React.ComponentType;
/** Component for the setup wizard admin creation step */
SetupStep?: React.ComponentType<{ onComplete: () => void }>;
}
/** All auth provider modules keyed by provider ID */
export type AuthProviders = Record<string, AuthProviderModule>;
const AuthProviderContext = createContext<AuthProviders>({});
export interface AuthProviderContextProps {
children: React.ReactNode;
authProviders: AuthProviders;
}
/**
* Provider that makes auth provider components available to all descendants
*/
export function AuthProviderProvider({ children, authProviders }: AuthProviderContextProps) {
return (
<AuthProviderContext.Provider value={authProviders}>{children}</AuthProviderContext.Provider>
);
}
/**
* Get all auth provider modules
*/
export function useAuthProviders(): AuthProviders {
return useContext(AuthProviderContext);
}
/**
* Get auth providers as an ordered array (buttons first, then forms)
*/
export function useAuthProviderList(): AuthProviderModule[] {
const providers = useContext(AuthProviderContext);
const list = Object.values(providers);
// Sort: providers with only LoginButton first (compact), then those with LoginForm
return list.toSorted((a, b) => {
const aHasForm = a.LoginForm ? 1 : 0;
const bHasForm = b.LoginForm ? 1 : 0;
return aHasForm - bHasForm;
});
}

View File

@@ -0,0 +1,28 @@
/**
* Helpers for round-tripping `datetime` field values through
* `<input type="datetime-local">`.
*
* Stored field values are full ISO 8601 (`YYYY-MM-DDTHH:mm:ss.sssZ`) or
* date-only (`YYYY-MM-DD`) per the per-field zod schema in
* `packages/core/src/schema/zod-generator.ts`. The browser input only
* accepts `YYYY-MM-DDTHH:mm`, so widgets must convert in both directions.
*
* The widget treats the value as UTC for a stable round-trip. Using
* `new Date(...).toISOString()` (the convention in the publish-schedule
* UI in `ContentEditor.tsx`) would shift values by the local-UTC offset on
* every save, mutating the persisted time on each edit cycle.
*/
/** Format a stored datetime field value for the input. */
export function toDatetimeLocalInputValue(value: unknown): string {
if (typeof value !== "string" || value === "") return "";
// `YYYY-MM-DD` (date-only branch of the schema): pad to UTC midnight.
if (value.length === 10) return `${value}T00:00`;
// Full ISO 8601: take the date + `HH:mm` prefix.
return value.slice(0, 16);
}
/** Convert an input value (`YYYY-MM-DDTHH:mm`) back to the stored ISO shape. */
export function fromDatetimeLocalInputValue(value: string): string {
return value === "" ? "" : `${value}:00.000Z`;
}

View File

@@ -0,0 +1,31 @@
import * as React from "react";
/**
* Returns a stable function reference that always calls the latest version
* of the provided callback. Useful for event listeners in effects where
* you don't want the listener to be torn down and re-added when the
* callback identity changes.
*/
export function useStableCallback<Args extends unknown[], Return>(
callback: (...args: Args) => Return,
): (...args: Args) => Return {
const ref = React.useRef(callback);
React.useLayoutEffect(() => {
ref.current = callback;
});
return React.useCallback((...args: Args) => ref.current(...args), []);
}
/**
* Returns a debounced version of a value that only updates after the
* specified delay has elapsed since the last change. Useful for search
* inputs that trigger API calls.
*/
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = React.useState(value);
React.useEffect(() => {
const timer = setTimeout(setDebouncedValue, delay, value);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,37 @@
import type { MediaItem, MediaProviderItem } from "./api/media.js";
export function providerItemToMediaItem(
providerId: string,
item: MediaProviderItem,
): MediaItem & { provider: string; meta?: Record<string, unknown> } {
return {
id: item.id,
filename: item.filename,
mimeType: item.mimeType,
url: item.previewUrl || "",
size: item.size || 0,
width: item.width,
height: item.height,
alt: item.alt,
createdAt: new Date().toISOString(),
provider: providerId,
meta: item.meta,
} as MediaItem & { provider: string; meta?: Record<string, unknown> };
}
export function getFileIcon(mimeType: string): string {
if (mimeType.startsWith("video/")) return "🎬";
if (mimeType.startsWith("audio/")) return "🎵";
if (mimeType.includes("pdf")) return "📄";
if (mimeType.includes("document") || mimeType.includes("word")) return "📝";
if (mimeType.includes("spreadsheet") || mimeType.includes("excel")) return "📊";
return "📁";
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}

View File

@@ -0,0 +1,83 @@
/**
* Plugin Admin Context
*
* Provides plugin admin modules (widgets, pages, fields) to the admin UI
* via React context. This avoids cross-module registry issues by keeping
* everything in React's component tree.
*/
import * as React from "react";
import { createContext, useContext } from "react";
/** Shape of a plugin's admin exports */
export interface PluginAdminModule {
widgets?: Record<string, React.ComponentType>;
pages?: Record<string, React.ComponentType>;
fields?: Record<string, React.ComponentType>;
}
/** All plugin admin modules keyed by plugin ID */
export type PluginAdmins = Record<string, PluginAdminModule>;
const PluginAdminContext = createContext<PluginAdmins>({});
export interface PluginAdminProviderProps {
children: React.ReactNode;
pluginAdmins: PluginAdmins;
}
/**
* Provider that makes plugin admin modules available to all descendants
*/
export function PluginAdminProvider({ children, pluginAdmins }: PluginAdminProviderProps) {
return <PluginAdminContext.Provider value={pluginAdmins}>{children}</PluginAdminContext.Provider>;
}
/**
* Get all plugin admin modules
*/
export function usePluginAdmins(): PluginAdmins {
return useContext(PluginAdminContext);
}
/**
* Get a dashboard widget component by plugin ID and widget ID
*/
export function usePluginWidget(pluginId: string, widgetId: string): React.ComponentType | null {
const admins = useContext(PluginAdminContext);
return admins[pluginId]?.widgets?.[widgetId] ?? null;
}
/**
* Get a plugin page component by plugin ID and path
*/
export function usePluginPage(pluginId: string, path: string): React.ComponentType | null {
const admins = useContext(PluginAdminContext);
return admins[pluginId]?.pages?.[path] ?? null;
}
/**
* Get a field widget component by plugin ID and field type
*/
export function usePluginField(pluginId: string, fieldType: string): React.ComponentType | null {
const admins = useContext(PluginAdminContext);
return admins[pluginId]?.fields?.[fieldType] ?? null;
}
/**
* Check if a plugin has any registered admin pages
*/
export function usePluginHasPages(pluginId: string): boolean {
const admins = useContext(PluginAdminContext);
const pages = admins[pluginId]?.pages;
return pages !== undefined && Object.keys(pages).length > 0;
}
/**
* Check if a plugin has any registered dashboard widgets
*/
export function usePluginHasWidgets(pluginId: string): boolean {
const admins = useContext(PluginAdminContext);
const widgets = admins[pluginId]?.widgets;
return widgets !== undefined && Object.keys(widgets).length > 0;
}

View File

@@ -0,0 +1,15 @@
import type { PluginBlockDef } from "../components/PortableTextEditor";
import type { AdminManifest } from "./api";
/** Extract plugin block definitions from the manifest for the Portable Text editor. */
export function getPluginBlocks(manifest: AdminManifest): PluginBlockDef[] {
const blocks: PluginBlockDef[] = [];
for (const [pluginId, plugin] of Object.entries(manifest.plugins)) {
if (plugin.portableTextBlocks) {
for (const block of plugin.portableTextBlocks) {
blocks.push({ ...block, pluginId });
}
}
}
return blocks;
}

View File

@@ -0,0 +1,68 @@
/**
* Taxonomy term matching for the admin picker.
*
* The picker filters an editor's typed input against existing terms. A naive
* `label.toLowerCase().includes(input.toLowerCase())` fails the accent case:
* typing `"Mexico"` does not substring-match a term labeled `"México"`
* because `"méxico".toLowerCase()` is still `"méxico"` and
* `"méxico".includes("mexico")` is `false`. The editor then sees zero
* suggestions and creates a duplicate `"Mexico"` term alongside the
* canonical `"México"`, splitting the taxonomy.
*
* This module folds diacritics via NFD decomposition before substring
* matching. No regexes are compiled from user input, so there is no ReDoS
* surface.
*/
const DIACRITIC_RANGE = /[\u0300-\u036f]/g;
/**
* Case-fold + diacritic-fold normalization for substring matching.
*
* `"México"`, `"mexico"`, `"MÉXICO"` all collapse to `"mexico"`.
*
* NFD decomposes accented characters into a base + combining-diacritic
* sequence; the regex drops the combiners. Greek tonos, Vietnamese
* stacked diacritics, and other Latin-adjacent scripts are covered.
* Combining marks used meaningfully in non-Latin scripts (Arabic harakat
* U+064BU+0652, Japanese dakuten U+3099) fall outside the U+0300036F
* block and are left untouched — stripping them would change meaning.
*/
export function foldForMatch(value: string): string {
return value.normalize("NFD").replace(DIACRITIC_RANGE, "").toLowerCase();
}
/**
* Minimal shape a term must have to participate in matching.
* Kept structural so picker components and tests can use plain objects.
*/
export interface MatchableTerm {
label: string;
}
/**
* True if `input` is a substring of the term's label, ignoring case and
* diacritics.
*
* Empty or whitespace-only input returns `false` — the caller decides
* whether to show all terms or none in that state. The whitespace guard
* matters: without it, a needle of `" "` would `.includes()`-match
* every term whose label contains a space.
*/
export function termMatches(term: MatchableTerm, input: string): boolean {
const needle = foldForMatch(input).trim();
if (!needle) return false;
return foldForMatch(term.label).includes(needle);
}
/**
* True if `input` is an exact (fold-equal) match for the term's label.
* Used to decide whether to show the "Create new term" button — if an
* editor types `"Mexico"` and a term labeled `"México"` already exists,
* Create must not appear or they'll produce a duplicate.
*/
export function termExactMatches(term: MatchableTerm, input: string): boolean {
const needle = foldForMatch(input).trim();
if (!needle) return false;
return foldForMatch(term.label).trim() === needle;
}

View File

@@ -0,0 +1,57 @@
/**
* Shared URL validation and transformation utilities
*/
const DEFAULT_REDIRECT = "/_emdash/admin";
const LEADING_SLASHES = /^\/+/;
/**
* Sanitize a redirect URL to prevent open-redirect and javascript: XSS attacks.
*
* Only allows relative paths starting with `/`. Rejects protocol-relative
* URLs (`//evil.com`), backslash tricks (`/\evil.com`), and non-path schemes
* like `javascript:`.
*
* Returns the default admin URL when the input is unsafe.
*/
export function sanitizeRedirectUrl(raw: string): string {
if (raw.startsWith("/") && !raw.startsWith("//") && !raw.includes("\\")) {
return raw;
}
return DEFAULT_REDIRECT;
}
/**
* Build a public content URL from collection metadata and slug.
*
* Uses the collection's `urlPattern` when available (e.g. `/blog/{slug}`),
* otherwise falls back to `/{collection}/{slug}`. Leading slashes are
* stripped from the slug to prevent protocol-relative URLs.
*/
export function contentUrl(collection: string, slug: string, urlPattern?: string): string {
const safe = slug.replace(LEADING_SLASHES, "");
return urlPattern ? urlPattern.replace("{slug}", safe) : `/${collection}/${safe}`;
}
/** Matches http:// or https:// URLs */
export const SAFE_URL_RE = /^https?:\/\//i;
/** Returns true if the URL uses a safe scheme (http/https) */
export function isSafeUrl(url: string): boolean {
return SAFE_URL_RE.test(url);
}
/**
* Build an icon URL with a width query param, or return null for unsafe URLs.
* Validates the URL scheme and appends `?w=<width>` for image resizing.
*/
export function safeIconUrl(url: string, width: number): string | null {
if (!SAFE_URL_RE.test(url)) return null;
try {
const u = new URL(url);
u.searchParams.set("w", String(width));
return u.href;
} catch {
return null;
}
}

View File

@@ -0,0 +1,53 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
// Regex patterns for slugify
const DIACRITICS_PATTERN = /[\u0300-\u036f]/g;
const WHITESPACE_UNDERSCORE_PATTERN = /[\s_]+/g;
const NON_ALPHANUMERIC_HYPHEN_PATTERN = /[^a-z0-9-]/g;
const MULTIPLE_HYPHENS_PATTERN = /-+/g;
const LEADING_TRAILING_HYPHEN_PATTERN = /^-|-$/g;
/**
* Merge class names with Tailwind CSS support
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Convert a string to a URL-friendly slug.
*
* Handles unicode by normalizing to NFD and stripping diacritics.
*/
export function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return "just now";
if (diffMins < 60) return `${diffMins} min${diffMins === 1 ? "" : "s"} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function slugify(text: string): string {
return text
.toLowerCase()
.normalize("NFD")
.replace(DIACRITICS_PATTERN, "")
.replace(WHITESPACE_UNDERSCORE_PATTERN, "-")
.replace(NON_ALPHANUMERIC_HYPHEN_PATTERN, "")
.replace(MULTIPLE_HYPHENS_PATTERN, "-")
.replace(LEADING_TRAILING_HYPHEN_PATTERN, "");
}

View File

@@ -0,0 +1,25 @@
/**
* WebAuthn is only available in a browser "secure context": HTTPS, or special-cased
* loopback hosts such as `http://localhost` / `http://127.0.0.1`.
*
* An origin like `http://emdash.local:8081` resolves to 127.0.0.1 but is still
* **not** a secure context, so `PublicKeyCredential` is hidden — the same symptom
* as an unsupported browser.
*/
export function isWebAuthnSecureContext(): boolean {
return typeof window !== "undefined" && window.isSecureContext;
}
export function isPublicKeyCredentialConstructorAvailable(): boolean {
return (
typeof window !== "undefined" &&
window.PublicKeyCredential !== undefined &&
typeof window.PublicKeyCredential === "function"
);
}
/** True when the page can use `navigator.credentials` for passkeys. */
export function isPasskeyEnvironmentUsable(): boolean {
return isWebAuthnSecureContext() && isPublicKeyCredentialConstructorAvailable();
}