vestrycms 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -2
  3. package/dist/config/fields.d.ts +30 -0
  4. package/dist/config/fields.js +75 -0
  5. package/dist/config/index.d.ts +9 -0
  6. package/dist/config/index.js +18 -0
  7. package/dist/config/types.d.ts +403 -0
  8. package/dist/config/types.js +10 -0
  9. package/dist/core/api.d.ts +67 -0
  10. package/dist/core/api.js +538 -0
  11. package/dist/core/auth.d.ts +1561 -0
  12. package/dist/core/auth.js +234 -0
  13. package/dist/core/cache.d.ts +27 -0
  14. package/dist/core/cache.js +52 -0
  15. package/dist/core/entries.d.ts +19 -0
  16. package/dist/core/entries.js +127 -0
  17. package/dist/core/index-types.d.ts +20 -0
  18. package/dist/core/index-types.js +1 -0
  19. package/dist/core/index.d.ts +13 -0
  20. package/dist/core/index.js +13 -0
  21. package/dist/core/mail.d.ts +28 -0
  22. package/dist/core/mail.js +62 -0
  23. package/dist/core/paths.d.ts +35 -0
  24. package/dist/core/paths.js +148 -0
  25. package/dist/core/roles.d.ts +24 -0
  26. package/dist/core/roles.js +15 -0
  27. package/dist/core/service.d.ts +102 -0
  28. package/dist/core/service.js +478 -0
  29. package/dist/core/source.d.ts +82 -0
  30. package/dist/core/source.js +75 -0
  31. package/dist/core/sources/github-app.d.ts +26 -0
  32. package/dist/core/sources/github-app.js +161 -0
  33. package/dist/core/sources/github.d.ts +40 -0
  34. package/dist/core/sources/github.js +278 -0
  35. package/dist/core/sources/local.d.ts +16 -0
  36. package/dist/core/sources/local.js +98 -0
  37. package/dist/core/values.d.ts +22 -0
  38. package/dist/core/values.js +123 -0
  39. package/dist/env.d.ts +19 -0
  40. package/dist/index.d.ts +18 -0
  41. package/dist/index.js +167 -0
  42. package/dist/integration/build-index.d.ts +8 -0
  43. package/dist/integration/build-index.js +67 -0
  44. package/dist/integration/client-deps.d.ts +10 -0
  45. package/dist/integration/client-deps.js +43 -0
  46. package/dist/integration/dev-middleware.d.ts +6 -0
  47. package/dist/integration/dev-middleware.js +48 -0
  48. package/dist/integration/formatter.d.ts +7 -0
  49. package/dist/integration/formatter.js +56 -0
  50. package/dist/mdx/expressions.d.ts +14 -0
  51. package/dist/mdx/expressions.js +141 -0
  52. package/dist/mdx/index.d.ts +4 -0
  53. package/dist/mdx/index.js +4 -0
  54. package/dist/mdx/parse.d.ts +6 -0
  55. package/dist/mdx/parse.js +378 -0
  56. package/dist/mdx/serialize.d.ts +9 -0
  57. package/dist/mdx/serialize.js +396 -0
  58. package/dist/mdx/types.d.ts +40 -0
  59. package/dist/mdx/types.js +6 -0
  60. package/dist/pages/admin.astro +27 -0
  61. package/dist/pages/api.d.ts +3 -0
  62. package/dist/pages/api.js +31 -0
  63. package/dist/ui/App.d.ts +4 -0
  64. package/dist/ui/App.js +70 -0
  65. package/dist/ui/api.d.ts +83 -0
  66. package/dist/ui/api.js +97 -0
  67. package/dist/ui/auth-client.d.ts +27 -0
  68. package/dist/ui/auth-client.js +29 -0
  69. package/dist/ui/components/AssetPicker.d.ts +27 -0
  70. package/dist/ui/components/AssetPicker.js +97 -0
  71. package/dist/ui/components/Icon.d.ts +11 -0
  72. package/dist/ui/components/Icon.js +46 -0
  73. package/dist/ui/components/Layout.d.ts +15 -0
  74. package/dist/ui/components/Layout.js +87 -0
  75. package/dist/ui/components/editor/EditorContext.d.ts +15 -0
  76. package/dist/ui/components/editor/EditorContext.js +9 -0
  77. package/dist/ui/components/editor/NodeViews.d.ts +4 -0
  78. package/dist/ui/components/editor/NodeViews.js +127 -0
  79. package/dist/ui/components/editor/RichTextEditor.d.ts +24 -0
  80. package/dist/ui/components/editor/RichTextEditor.js +253 -0
  81. package/dist/ui/components/editor/extensions.d.ts +20 -0
  82. package/dist/ui/components/editor/extensions.js +156 -0
  83. package/dist/ui/components/fields/CodeInput.d.ts +10 -0
  84. package/dist/ui/components/fields/CodeInput.js +8 -0
  85. package/dist/ui/components/fields/CodeMirrorInput.d.ts +2 -0
  86. package/dist/ui/components/fields/CodeMirrorInput.js +44 -0
  87. package/dist/ui/components/fields/FieldRenderer.d.ts +41 -0
  88. package/dist/ui/components/fields/FieldRenderer.js +299 -0
  89. package/dist/ui/components/fields/ImageTools.d.ts +31 -0
  90. package/dist/ui/components/fields/ImageTools.js +296 -0
  91. package/dist/ui/components/fields/MarkdownField.d.ts +7 -0
  92. package/dist/ui/components/fields/MarkdownField.js +16 -0
  93. package/dist/ui/components/fields/TreeInput.d.ts +13 -0
  94. package/dist/ui/components/fields/TreeInput.js +221 -0
  95. package/dist/ui/components/ui.d.ts +63 -0
  96. package/dist/ui/components/ui.js +113 -0
  97. package/dist/ui/context.d.ts +17 -0
  98. package/dist/ui/context.js +13 -0
  99. package/dist/ui/lib/format.d.ts +5 -0
  100. package/dist/ui/lib/format.js +11 -0
  101. package/dist/ui/lib/utils.d.ts +24 -0
  102. package/dist/ui/lib/utils.js +255 -0
  103. package/dist/ui/main.d.ts +1 -0
  104. package/dist/ui/main.js +11 -0
  105. package/dist/ui/pages/Assets.d.ts +1 -0
  106. package/dist/ui/pages/Assets.js +69 -0
  107. package/dist/ui/pages/CollectionList.d.ts +6 -0
  108. package/dist/ui/pages/CollectionList.js +115 -0
  109. package/dist/ui/pages/Dashboard.d.ts +1 -0
  110. package/dist/ui/pages/Dashboard.js +57 -0
  111. package/dist/ui/pages/EntryEditor.d.ts +3 -0
  112. package/dist/ui/pages/EntryEditor.js +313 -0
  113. package/dist/ui/pages/Login.d.ts +6 -0
  114. package/dist/ui/pages/Login.js +88 -0
  115. package/dist/ui/pages/Users.d.ts +1 -0
  116. package/dist/ui/pages/Users.js +102 -0
  117. package/dist/ui/styles.css +2536 -0
  118. package/package.json +121 -3
@@ -0,0 +1,538 @@
1
+ import { isAPIError } from "better-auth/api";
2
+ import { createVestryAuth, nameFromEmail, normalizeEmail, } from "./auth.js";
3
+ import { MailError, consoleMailer, resendMailer } from "./mail.js";
4
+ import { isRole } from "./roles.js";
5
+ import { ContentService } from "./service.js";
6
+ import { SourceError, withAuthor } from "./source.js";
7
+ import { GitHubSource } from "./sources/github.js";
8
+ import { installationTokenProvider } from "./sources/github-app.js";
9
+ import { extname, normalizePath } from "./paths.js";
10
+ const MIME = {
11
+ jpg: "image/jpeg",
12
+ jpeg: "image/jpeg",
13
+ png: "image/png",
14
+ gif: "image/gif",
15
+ webp: "image/webp",
16
+ avif: "image/avif",
17
+ svg: "image/svg+xml",
18
+ pdf: "application/pdf",
19
+ mp4: "video/mp4",
20
+ mp3: "audio/mpeg",
21
+ json: "application/json",
22
+ txt: "text/plain; charset=utf-8",
23
+ md: "text/markdown; charset=utf-8",
24
+ mdx: "text/markdown; charset=utf-8",
25
+ };
26
+ export const DEFAULT_DATABASE_BINDING = "VESTRY_DB";
27
+ /** Better Auth routes the panel uses; everything else under /auth is closed. */
28
+ const AUTH_ROUTES = new Set([
29
+ "/email-otp/send-verification-otp",
30
+ "/sign-in/email-otp",
31
+ "/sign-out",
32
+ "/get-session",
33
+ "/ok",
34
+ ]);
35
+ const DEV_SECRET = "vestry-development-secret-do-not-use-in-production";
36
+ export function json(data, init = {}) {
37
+ const headers = new Headers(init.headers);
38
+ headers.set("Content-Type", "application/json; charset=utf-8");
39
+ headers.set("Cache-Control", "no-store");
40
+ return new Response(JSON.stringify(data), { ...init, headers });
41
+ }
42
+ function error(message, status = 400, code = "error") {
43
+ return json({ error: { message, code } }, { status });
44
+ }
45
+ const LOCAL_USER = {
46
+ id: "local",
47
+ email: "",
48
+ name: "Local development",
49
+ role: "admin",
50
+ status: "active",
51
+ origin: "config",
52
+ createdAt: "",
53
+ };
54
+ export function createApi(options) {
55
+ const { config, basePath } = options;
56
+ const apiPrefix = `${basePath.replace(/\/$/, "")}/api`;
57
+ const cookiePath = basePath.replace(/\/$/, "") || "/";
58
+ const authRequired = !options.localSource || options.auth?.required === true;
59
+ const databaseBinding = options.databaseBinding ?? DEFAULT_DATABASE_BINDING;
60
+ let remoteSource;
61
+ /**
62
+ * One Better Auth instance per request origin: production has one, dev may
63
+ * have a couple (localhost vs 127.0.0.1). Bounded because the Host header
64
+ * is client-controlled on some hosts.
65
+ */
66
+ const authByOrigin = new Map();
67
+ const MAX_ORIGINS = 4;
68
+ // ----------------------------------------------------------- resolvers
69
+ function resolveMailer() {
70
+ if (options.auth?.mailer)
71
+ return options.auth.mailer;
72
+ const mail = config.mail;
73
+ if (!mail) {
74
+ return {
75
+ error: "config.mail is not set. Vestry needs an email provider to send sign-in codes.",
76
+ };
77
+ }
78
+ if (mail.provider === "console")
79
+ return consoleMailer();
80
+ const apiKey = options.env("VESTRY_RESEND_API_KEY");
81
+ if (!apiKey)
82
+ return { error: "Missing environment variable: VESTRY_RESEND_API_KEY" };
83
+ return resendMailer({ apiKey, from: mail.from });
84
+ }
85
+ function resolveDatabase(ctx) {
86
+ if (options.auth?.database)
87
+ return options.auth.database;
88
+ const binding = ctx.bindings?.[databaseBinding];
89
+ if (isD1Like(binding))
90
+ return binding;
91
+ return {
92
+ error: `No ${databaseBinding} D1 binding. Add it to wrangler.jsonc so Vestry can store users and sessions.`,
93
+ };
94
+ }
95
+ function resolveSecret() {
96
+ const secret = options.auth?.secret ?? options.env("VESTRY_SECRET");
97
+ if (secret)
98
+ return secret;
99
+ if (options.auth?.dev)
100
+ return DEV_SECRET;
101
+ return {
102
+ error: "Missing environment variable: VESTRY_SECRET (a random string, e.g. `openssl rand -hex 32`).",
103
+ };
104
+ }
105
+ function resolveAuth(url, ctx) {
106
+ const cached = authByOrigin.get(url.origin);
107
+ if (cached)
108
+ return cached;
109
+ const database = resolveDatabase(ctx);
110
+ if ("error" in database)
111
+ return database;
112
+ const mailer = resolveMailer();
113
+ if ("error" in mailer)
114
+ return mailer;
115
+ const secret = resolveSecret();
116
+ if (typeof secret !== "string")
117
+ return secret;
118
+ // Invitation links point at the configured site, not whatever host the
119
+ // inviting admin happened to use (a preview or workers.dev URL).
120
+ const siteOrigin = !options.auth?.dev && config.siteUrl
121
+ ? config.siteUrl.replace(/\/$/, "")
122
+ : url.origin;
123
+ const created = createVestryAuth({
124
+ database,
125
+ mailer,
126
+ secret,
127
+ baseURL: url.origin,
128
+ basePath: `${apiPrefix}/auth`,
129
+ cookiePath,
130
+ siteName: config.name ?? "Vestry",
131
+ adminUrl: `${siteOrigin}${basePath}`,
132
+ admins: config.auth?.admins ?? [],
133
+ dev: options.auth?.dev,
134
+ migrate: options.auth?.migrate,
135
+ });
136
+ if (authByOrigin.size >= MAX_ORIGINS) {
137
+ const oldest = authByOrigin.keys().next().value;
138
+ if (oldest !== undefined)
139
+ authByOrigin.delete(oldest);
140
+ }
141
+ authByOrigin.set(url.origin, created);
142
+ return created;
143
+ }
144
+ function resolveSource() {
145
+ if (options.localSource)
146
+ return options.localSource;
147
+ if (remoteSource)
148
+ return remoteSource;
149
+ if (options.createSource)
150
+ return (remoteSource = options.createSource());
151
+ const appId = options.env("VESTRY_GITHUB_APP_ID");
152
+ const installationId = options.env("VESTRY_GITHUB_INSTALLATION_ID");
153
+ const privateKey = options.env("VESTRY_GITHUB_PRIVATE_KEY");
154
+ const missing = [
155
+ !appId && "VESTRY_GITHUB_APP_ID",
156
+ !installationId && "VESTRY_GITHUB_INSTALLATION_ID",
157
+ !privateKey && "VESTRY_GITHUB_PRIVATE_KEY",
158
+ ].filter(Boolean);
159
+ if (missing.length) {
160
+ return { error: `Missing environment variables: ${missing.join(", ")}` };
161
+ }
162
+ return (remoteSource = new GitHubSource({
163
+ owner: config.repo.owner,
164
+ repo: config.repo.name,
165
+ branch: config.repo.branch,
166
+ token: installationTokenProvider({
167
+ appId: appId,
168
+ installationId: installationId,
169
+ privateKey: privateKey,
170
+ }),
171
+ cache: options.cache,
172
+ }));
173
+ }
174
+ // ---------------------------------------------------------------- handle
175
+ async function handle(request, ctx = {}) {
176
+ // Better Auth may refresh the session while we check it; its Set-Cookie
177
+ // headers are collected here and added to whatever response goes out.
178
+ const cookies = [];
179
+ const response = await dispatch(request, ctx, cookies);
180
+ if (!cookies.length)
181
+ return response;
182
+ const out = new Response(response.body, response);
183
+ for (const cookie of cookies)
184
+ out.headers.append("Set-Cookie", cookie);
185
+ return out;
186
+ }
187
+ async function dispatch(request, ctx, cookies) {
188
+ const url = new URL(request.url);
189
+ const rel = url.pathname.startsWith(apiPrefix)
190
+ ? url.pathname.slice(apiPrefix.length)
191
+ : null;
192
+ if (rel === null)
193
+ return error("Not found", 404, "not_found");
194
+ let segments;
195
+ try {
196
+ segments = rel.split("/").filter(Boolean).map(decodeURIComponent);
197
+ }
198
+ catch {
199
+ return error("Malformed path", 400, "bad_request");
200
+ }
201
+ const method = request.method.toUpperCase();
202
+ // Cross-site writes are refused outright; the SameSite cookie already
203
+ // keeps other sites' requests anonymous, this is the second lock.
204
+ if (method !== "GET" && method !== "HEAD") {
205
+ const origin = request.headers.get("origin");
206
+ const fetchSite = request.headers.get("sec-fetch-site");
207
+ if ((origin && origin !== url.origin) || fetchSite === "cross-site") {
208
+ return error("Cross-site request refused", 403, "cross_site");
209
+ }
210
+ }
211
+ try {
212
+ // Everything that needs sign-in shares one setup check.
213
+ let setupError;
214
+ let vestryAuth = null;
215
+ if (authRequired) {
216
+ const resolved = resolveAuth(url, ctx);
217
+ if ("error" in resolved)
218
+ setupError = resolved.error;
219
+ else
220
+ vestryAuth = resolved;
221
+ }
222
+ if (vestryAuth)
223
+ await vestryAuth.ensureMigrated();
224
+ // ------------------------------------------------------------- auth
225
+ // Better Auth owns sign-in under /auth: sending and checking codes,
226
+ // sessions, sign-out. Only the routes the panel uses are reachable;
227
+ // the admin plugin's endpoints stay behind Vestry's /users routes so
228
+ // their guards (config admins, last admin) cannot be bypassed.
229
+ if (segments[0] === "auth") {
230
+ if (!authRequired)
231
+ return error("Sign-in is not used in local mode", 400, "local_mode");
232
+ if (!vestryAuth)
233
+ return error(setupError, 500, "setup");
234
+ const authPath = `/${segments.slice(1).join("/")}`;
235
+ if (!AUTH_ROUTES.has(authPath))
236
+ return error("Not found", 404, "not_found");
237
+ return vestryAuth.auth.handler(request);
238
+ }
239
+ let user = null;
240
+ if (vestryAuth) {
241
+ const { headers: sessionHeaders, response: session } = await vestryAuth.auth.api.getSession({
242
+ headers: request.headers,
243
+ returnHeaders: true,
244
+ });
245
+ cookies.push(...sessionHeaders.getSetCookie());
246
+ if (session)
247
+ user = vestryAuth.toPublicUser(session.user);
248
+ }
249
+ else if (!authRequired) {
250
+ user = LOCAL_USER;
251
+ }
252
+ // ----------------------------------------------------------- config
253
+ if (segments[0] === "config" && method === "GET") {
254
+ const source = resolveSource();
255
+ if ("error" in source)
256
+ setupError ??= source.error;
257
+ const info = "error" in source ? null : source.info();
258
+ const payload = {
259
+ config: user ? clientConfig(config) : publicConfig(config),
260
+ basePath,
261
+ mode: options.localSource ? "local" : "github",
262
+ auth: authRequired,
263
+ source: info
264
+ ? { label: info.label, branch: info.branch, url: info.url }
265
+ : {
266
+ label: `${config.repo.owner}/${config.repo.name}`,
267
+ branch: config.repo.branch,
268
+ url: `https://github.com/${config.repo.owner}/${config.repo.name}`,
269
+ },
270
+ user,
271
+ setupError,
272
+ };
273
+ return json(payload);
274
+ }
275
+ // The logo is shown on the sign-in page, so it is public.
276
+ if (segments[0] === "file" &&
277
+ method === "GET" &&
278
+ config.logo &&
279
+ normalizePath(url.searchParams.get("path") ?? "") ===
280
+ normalizePath(config.logo)) {
281
+ const source = resolveSource();
282
+ if ("error" in source)
283
+ return error(source.error, 500, "setup");
284
+ return serveFile(new ContentService(config, source, options.index), config.logo, request);
285
+ }
286
+ if (!user) {
287
+ return error(setupError ?? "Sign in required", 401, setupError ? "setup" : "unauthorized");
288
+ }
289
+ // ------------------------------------------------------------ users
290
+ if (segments[0] === "users") {
291
+ if (!vestryAuth) {
292
+ return error(setupError ?? "User management is not available in local mode", 400, setupError ? "setup" : "local_mode");
293
+ }
294
+ if (user.role !== "admin")
295
+ return error("Only admins can manage users.", 403, "forbidden");
296
+ const { auth, toPublicUser, isConfigAdmin, sendInvite } = vestryAuth;
297
+ const headers = request.headers;
298
+ const getUser = async (id) => {
299
+ try {
300
+ const found = await auth.api.getUser({ query: { id }, headers });
301
+ return found ? toPublicUser(found) : null;
302
+ }
303
+ catch (e) {
304
+ if (isAPIError(e) && e.statusCode === 404)
305
+ return null;
306
+ throw e;
307
+ }
308
+ };
309
+ const adminCount = async () => {
310
+ const { total } = await auth.api.listUsers({
311
+ query: { filterField: "role", filterValue: "admin", limit: 1 },
312
+ headers,
313
+ });
314
+ return total;
315
+ };
316
+ if (segments.length === 1 && method === "GET") {
317
+ const { users } = await auth.api.listUsers({
318
+ query: { limit: 1000, sortBy: "name", sortDirection: "asc" },
319
+ headers,
320
+ });
321
+ return json({ users: users.map(toPublicUser) });
322
+ }
323
+ if (segments.length === 1 && method === "POST") {
324
+ const body = (await request.json());
325
+ const email = normalizeEmail(String(body.email ?? ""));
326
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
327
+ return error("Enter a valid email address.", 400, "invalid_email");
328
+ const role = isRole(body.role) ? body.role : "editor";
329
+ const created = await auth.api.createUser({
330
+ body: {
331
+ email,
332
+ name: body.name?.trim() || nameFromEmail(email),
333
+ role: isConfigAdmin(email) ? "admin" : role,
334
+ },
335
+ headers,
336
+ });
337
+ const invited = toPublicUser(created.user);
338
+ // The user exists from here on; a failed email must not hide them.
339
+ let mailError;
340
+ try {
341
+ await sendInvite(user, invited);
342
+ }
343
+ catch (e) {
344
+ mailError = e instanceof Error ? e.message : String(e);
345
+ console.error("[vestry] invitation email failed", e);
346
+ }
347
+ return json({ user: invited, mailError }, { status: 201 });
348
+ }
349
+ const id = segments[1];
350
+ if (id && segments.length === 2 && method === "PATCH") {
351
+ const body = (await request.json());
352
+ const target = await getUser(id);
353
+ if (!target)
354
+ return error("User not found.", 404, "not_found");
355
+ if (body.role !== undefined && body.role !== target.role) {
356
+ if (!isRole(body.role))
357
+ return error("Unknown role.", 400, "invalid_role");
358
+ if (target.origin === "config") {
359
+ return error("This address is listed as an admin in the Vestry config, so its role can only be changed there.", 400, "config_admin");
360
+ }
361
+ if (target.role === "admin" && (await adminCount()) <= 1) {
362
+ return error(`${target.name} is the only admin. Make someone else an admin first.`, 400, "last_admin");
363
+ }
364
+ await auth.api.setRole({
365
+ body: { userId: id, role: body.role },
366
+ headers,
367
+ });
368
+ }
369
+ if (body.name !== undefined && body.name.trim()) {
370
+ await auth.api.adminUpdateUser({
371
+ body: { userId: id, data: { name: body.name.trim() } },
372
+ headers,
373
+ });
374
+ }
375
+ return json({ user: await getUser(id) });
376
+ }
377
+ if (id && segments.length === 2 && method === "DELETE") {
378
+ if (id === user.id)
379
+ return error("You can't remove yourself.", 400, "self");
380
+ const target = await getUser(id);
381
+ if (!target)
382
+ return json({ ok: true });
383
+ if (target.origin === "config") {
384
+ return error("This address is listed as an admin in the Vestry config. Remove it there first.", 400, "config_admin");
385
+ }
386
+ if (target.role === "admin" && (await adminCount()) <= 1) {
387
+ return error(`${target.name} is the only admin. Make someone else an admin first.`, 400, "last_admin");
388
+ }
389
+ await auth.api.removeUser({ body: { userId: id }, headers });
390
+ return json({ ok: true });
391
+ }
392
+ if (id && segments[2] === "invite" && method === "POST") {
393
+ const target = await getUser(id);
394
+ if (!target)
395
+ return error("User not found.", 404, "not_found");
396
+ await sendInvite(user, target);
397
+ return json({ user: target });
398
+ }
399
+ return error("Not found", 404, "not_found");
400
+ }
401
+ // ----------------------------------------------------------- content
402
+ const baseSource = resolveSource();
403
+ if ("error" in baseSource)
404
+ return error(baseSource.error, 500, "setup");
405
+ const source = user.email
406
+ ? withAuthor(baseSource, { name: user.name, email: user.email })
407
+ : baseSource;
408
+ const service = new ContentService(config, source, options.index);
409
+ if (segments[0] === "status" && method === "GET") {
410
+ return json({ head: await source.head(), source: source.info() });
411
+ }
412
+ // ------------------------------------------------------ collections
413
+ if (segments[0] === "collections" && segments[1]) {
414
+ const key = segments[1];
415
+ if (segments.length === 2 && method === "GET") {
416
+ return json({
417
+ entries: await service.listEntries(key),
418
+ head: await source.head(),
419
+ });
420
+ }
421
+ if (segments[2] === "entries") {
422
+ const slug = segments.slice(3).join("/");
423
+ if (!slug && method === "POST") {
424
+ const input = (await request.json());
425
+ return json(await service.saveEntry(key, null, input), {
426
+ status: 201,
427
+ });
428
+ }
429
+ if (slug && method === "GET") {
430
+ const entry = await service.getEntry(key, slug);
431
+ return entry
432
+ ? json(entry)
433
+ : error("Entry not found", 404, "not_found");
434
+ }
435
+ if (slug && method === "PUT") {
436
+ const input = (await request.json());
437
+ return json(await service.saveEntry(key, slug, input));
438
+ }
439
+ if (slug && method === "DELETE") {
440
+ return json(await service.deleteEntry(key, slug));
441
+ }
442
+ }
443
+ return error("Not found", 404, "not_found");
444
+ }
445
+ // ------------------------------------------------------- singletons
446
+ if (segments[0] === "singletons" && segments[1]) {
447
+ const key = segments[1];
448
+ if (method === "GET")
449
+ return json(await service.getSingleton(key));
450
+ if (method === "PUT") {
451
+ const input = (await request.json());
452
+ return json(await service.saveSingleton(key, input));
453
+ }
454
+ return error("Not found", 404, "not_found");
455
+ }
456
+ // ----------------------------------------------------------- assets
457
+ if (segments[0] === "assets" && segments[1]) {
458
+ const container = segments[1];
459
+ const dir = url.searchParams.get("dir") ?? "";
460
+ if (method === "GET")
461
+ return json(await service.listAssets(container, dir));
462
+ if (method === "POST") {
463
+ const body = (await request.json());
464
+ return json(await service.uploadAssets(container, body.dir ?? dir, body.files ?? []), { status: 201 });
465
+ }
466
+ if (method === "DELETE") {
467
+ const body = (await request.json());
468
+ return json(await service.deleteAsset(container, body.path));
469
+ }
470
+ return error("Not found", 404, "not_found");
471
+ }
472
+ // ------------------------------------------------------------- file
473
+ if (segments[0] === "file" && method === "GET") {
474
+ const path = url.searchParams.get("path");
475
+ if (!path)
476
+ return error("Missing path", 400);
477
+ return serveFile(service, path, request);
478
+ }
479
+ return error("Not found", 404, "not_found");
480
+ }
481
+ catch (e) {
482
+ if (isAPIError(e)) {
483
+ const body = e.body;
484
+ return error(body?.message ?? e.message, e.statusCode, body?.code?.toLowerCase() ?? "auth_error");
485
+ }
486
+ if (e instanceof SourceError)
487
+ return error(e.message, e.status, e.code);
488
+ if (e instanceof MailError)
489
+ return error(e.message, 502, "mail");
490
+ if (e instanceof SyntaxError)
491
+ return error(`Invalid request: ${e.message}`, 400, "bad_request");
492
+ console.error("[vestry] API error", e);
493
+ return error(e instanceof Error ? e.message : "Unexpected error", 500, "internal");
494
+ }
495
+ }
496
+ return { handle, apiPrefix };
497
+ }
498
+ /** A Cloudflare D1 binding, by its shape (the type lives in workers-types). */
499
+ function isD1Like(value) {
500
+ return (typeof value === "object" &&
501
+ value !== null &&
502
+ typeof value.prepare === "function" &&
503
+ typeof value.batch === "function" &&
504
+ typeof value.exec === "function");
505
+ }
506
+ /** What signed-in users receive: everything the panel needs, minus mail settings. */
507
+ function clientConfig(config) {
508
+ const { mail: _mail, ...rest } = config;
509
+ return rest;
510
+ }
511
+ /** What the sign-in page receives: branding only, no schema, no admin list. */
512
+ function publicConfig(config) {
513
+ return {
514
+ name: config.name,
515
+ logo: config.logo,
516
+ siteUrl: config.siteUrl,
517
+ repo: config.repo,
518
+ };
519
+ }
520
+ async function serveFile(service, path, request) {
521
+ const file = await service.readFile(path);
522
+ if (!file)
523
+ return error("File not found", 404, "not_found");
524
+ const etag = `"${file.sha}"`;
525
+ if (request.headers.get("if-none-match") === etag) {
526
+ return new Response(null, { status: 304, headers: { ETag: etag } });
527
+ }
528
+ const ext = extname(normalizePath(path));
529
+ return new Response(file.bytes, {
530
+ headers: {
531
+ "Content-Type": MIME[ext] ?? "application/octet-stream",
532
+ "Cache-Control": "private, max-age=0, must-revalidate",
533
+ ETag: etag,
534
+ "X-Content-Type-Options": "nosniff",
535
+ "Content-Security-Policy": "sandbox",
536
+ },
537
+ });
538
+ }