forked from emdash-cms/emdash
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.ts
More file actions
260 lines (247 loc) · 7.59 KB
/
Copy pathclient.ts
File metadata and controls
260 lines (247 loc) · 7.59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
/**
* Base API client configuration and shared types
*/
import type { Element } from "@emdash-cms/blocks";
import { i18n } from "@lingui/core";
import { msg } from "@lingui/core/macro";
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 });
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/**
* Extract per-field validation issue messages from a `VALIDATION_ERROR`
* response's `error.details.issues` array (see `packages/core/src/api/parse.ts`).
* Returns undefined when the shape doesn't match, so callers can fall back
* to the generic top-level message.
*/
function formatValidationIssues(error: Record<string, unknown>): string | undefined {
if (error.code !== "VALIDATION_ERROR") return undefined;
if (!isRecord(error.details)) return undefined;
const issues = error.details.issues;
if (!Array.isArray(issues) || issues.length === 0) return undefined;
const messages = issues
.map((issue: unknown) => {
if (!isRecord(issue)) return undefined;
const { path, message } = issue;
if (typeof message !== "string") return undefined;
return typeof path === "string" && path.length > 0 ? `${path}: ${message}` : message;
})
.filter((m): m is string => m !== undefined);
return messages.length > 0 ? messages.join("; ") : undefined;
}
/**
* 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
* `{ success: false, error: { code, message, details? } }`. For validation
* errors, the field-level messages in `error.details.issues` are surfaced
* instead of the generic "Invalid request data" top-level message.
*/
export async function throwResponseError(res: Response, fallback: string): Promise<never> {
const body: unknown = await res.json().catch(() => ({}));
let message: string | undefined;
if (isRecord(body) && isRecord(body.error)) {
const { error } = body;
message = formatValidationIssues(error);
if (!message && typeof error.message === "string") message = error.message;
}
throw new Error(message || `${fallback}: ${res.statusText}`);
}
/**
* Generic paginated result
*/
export interface FindManyResult<T> {
items: T[];
nextCursor?: string;
/**
* Total number of rows matching the filters (ignoring pagination).
* Optional because older servers may not return it.
*/
total?: number;
}
/**
* Admin manifest describing available collections and plugins
*/
export interface AdminManifest {
version: string;
/** Version of Astro the host is built with, when resolvable. */
astroVersion?: string;
hash: string;
collections: Record<
string,
{
label: string;
labelSingular: string;
supports: string[];
hasSeo: boolean;
urlPattern?: string;
titleField?: string;
dateField?: string;
hidden?: boolean;
listColumns?: string[];
fields: Record<
string,
{
/** Database row ID (ULID) for the field. Used to widen MIME allowlists on upload/media-list calls. */
id?: 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;
/**
* Experimental decentralized plugin registry. Present when
* `experimental.registry` is configured in the EmDash integration.
* When present, the admin UI uses the registry instead of the
* centralized marketplace for browse and install.
*/
registry?: {
aggregatorUrl: string;
acceptLabelers?: string;
policy?: {
minimumReleaseAgeSeconds?: number;
minimumReleaseAgeExclude?: 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 { success, 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 = i18n._(msg`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, i18n._(msg`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, i18n._(msg`Failed to fetch auth mode`));
}