@aotter/mantle-admin 0.1.0-alpha.10

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.
@@ -0,0 +1,1524 @@
1
+ import { DiagnosticError, HTTP_STATUS_BY_CODE, MANTLE_REF_KEYWORD, MCP_HINT_KEYWORD, VIEW_PARAMS_RESERVED, isMediaMcpHint, httpStatusFor, meetsRole, redactForWire, runtimeDiagnostic, checkSchemaAdminUi, checkViewAdminUi, schemaSortableFields, STAFF_ROLES, } from "@aotter/mantle-spec";
2
+ import { ViewParamCoercionError, coerceViewParams, evaluateAuthAll, } from "@aotter/mantle-runtime";
3
+ const STAFF_ROLE_SET = new Set(STAFF_ROLES);
4
+ const [PAGE_PARAM, SHOW_PARAM] = VIEW_PARAMS_RESERVED;
5
+ const MEMBER_CURSOR_PREFIX = "m:";
6
+ export function encodeMemberCursor(createdAt, id) {
7
+ return `${MEMBER_CURSOR_PREFIX}${encodeURIComponent(JSON.stringify([createdAt, id]))}`;
8
+ }
9
+ export function decodeMemberCursor(cursor) {
10
+ if (!cursor.startsWith(MEMBER_CURSOR_PREFIX))
11
+ return null;
12
+ try {
13
+ const value = JSON.parse(decodeURIComponent(cursor.slice(MEMBER_CURSOR_PREFIX.length)));
14
+ return Array.isArray(value) && value.length === 2 &&
15
+ typeof value[0] === "string" && !Number.isNaN(Date.parse(value[0])) &&
16
+ typeof value[1] === "string" && value[1]
17
+ ? [value[0], value[1]]
18
+ : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ /** Mount the optional Admin API, auth routes, and SPA assets. */
25
+ export function mountMantleAdmin(app, ref) {
26
+ const auth = ref.auth;
27
+ const authBasePath = auth.basePath;
28
+ const spa = async (c) => {
29
+ const asset = await ref.assets.fetch(new Request(new URL("/_mantle/admin/index.html", c.req.url)));
30
+ return asset ?? new Response("Mantle Admin assets are missing; run `mantle generate` and configure the ASSETS binding.", { status: 503, headers: { "cache-control": "private, no-store" } });
31
+ };
32
+ // Public read-only manifest of registered sign-in methods. The admin
33
+ // SPA hits this on sign-in-page mount so it can render per-method
34
+ // sections without baking the method list into its build. No secrets
35
+ // or sender refs — only the `kind` strings.
36
+ //
37
+ // `Cache-Control: no-store` because the list reflects deploy-time
38
+ // config; if the operator rolls a new method, a CDN-cached response
39
+ // would silently misroute the sign-in UI until the cache expires.
40
+ app.get(`${authBasePath}/methods`, () => Response.json({ methods: auth.methods }, {
41
+ headers: { "cache-control": "no-store" },
42
+ }));
43
+ // Better Auth handles the rest of the configured auth base path
44
+ // (sign-in, callback,
45
+ // session, magic-link, OTP, OAuth provider routes). Owned by the SDK
46
+ // so consumers don't have to wire — and can't accidentally register a
47
+ // catch-all BEFORE the specific routes above and silently swallow
48
+ // them. Hono matches in registration order; this catch-all sits last.
49
+ app.all(`${authBasePath}/*`, (c) => auth.handler(c.req.raw));
50
+ // Better Auth's provider serves discovery outside its base path. Keep these
51
+ // explicit so a consumer catch-all cannot swallow RFC 8414/9728 metadata.
52
+ for (const path of [
53
+ "/.well-known/oauth-authorization-server/*",
54
+ "/.well-known/oauth-protected-resource",
55
+ "/.well-known/oauth-protected-resource/*",
56
+ ]) {
57
+ app.all(path, (c) => auth.handler(c.req.raw));
58
+ }
59
+ for (const path of [
60
+ "/admin",
61
+ "/admin/",
62
+ "/admin/sign-in",
63
+ "/admin/c/:collection",
64
+ "/admin/c/:collection/:id",
65
+ "/admin/media",
66
+ "/admin/preferences",
67
+ "/admin/settings",
68
+ "/admin/staff",
69
+ "/admin/members",
70
+ "/admin/ops",
71
+ "/admin/views/:name",
72
+ ]) {
73
+ app.get(path, spa);
74
+ }
75
+ // Pre-derive the collections projection — `ref.plan` is
76
+ // immutable post-boot, so the filter / Set / mediaFields work doesn't
77
+ // need to repeat per request.
78
+ const schemas = Object.values(ref.plan.schemas).map(({ manifest }) => manifest);
79
+ const schemasByName = new Map(schemas.map((s) => [s.metadata.name, s]));
80
+ const assertMutableSchema = (path, schema) => {
81
+ if (schema?.spec.schema.readOnly !== true)
82
+ return;
83
+ throw new DiagnosticError(runtimeDiagnostic({
84
+ code: "CONFLICT",
85
+ severity: "error",
86
+ path,
87
+ value: schema.metadata.name,
88
+ expected: "a Schema without root readOnly: true",
89
+ message: `Schema '${schema.metadata.name}' is read-only on generic authoring surfaces; use its declared Procedures.`,
90
+ }));
91
+ };
92
+ const readMutableEntry = async (runtime, id, path) => {
93
+ const entry = await runtime.getEntry.execute({ id });
94
+ assertMutableSchema(path, schemasByName.get(entry.collection));
95
+ return entry;
96
+ };
97
+ const collections = schemas
98
+ .filter((s) => !s.spec.translates)
99
+ .map((s) => adminEditorCollection(s, schemas));
100
+ // Staff-operable Procedures (#426, extended #430 with rowBindings):
101
+ // precompute at mount, same as `collections` above — `ref.plan`
102
+ // is immutable post-boot.
103
+ const operations = discoverStaffOperations(ref.plan, schemasByName);
104
+ const operationsByName = new Map(operations.map((op) => [op.name, op]));
105
+ // Views projection (#426) — least-code option: a dedicated
106
+ // `/admin/api/views-manifest` endpoint rather than folding into
107
+ // `/admin/api/site`. Reasoning: `/admin/api/site` payload is
108
+ // site-config-shaped (title/brand/locales/mcp URLs) and consumed by
109
+ // several unrelated views (entry editor, settings) via a shared
110
+ // `SiteInfo` query key; growing it with an unrelated `views: [...]`
111
+ // array would force every one of those call sites to widen their
112
+ // type and would invalidate/refetch on unrelated site-settings
113
+ // changes. A dedicated guarded route mirrors the existing
114
+ // `/admin/api/collections` precedent exactly (same shape of
115
+ // "precompute at mount from ref.plan, list on GET") and needs
116
+ // no changes to the `SiteInfo` type or its query key.
117
+ // Report-sidebar source (#433): ONLY `surface: staff` Views. Public
118
+ // storefront Views explicitly marked public auto-mount on the public REST
119
+ // path and must not appear in the admin report sidebar — listing
120
+ // them was noise + broke on param-driven storefront Views (see #433).
121
+ const staffViews = Object.values(ref.plan.views)
122
+ .map(({ manifest }) => manifest)
123
+ .filter((view) => view.spec.surface === "staff");
124
+ const viewsManifest = staffViews.map((v) => ({
125
+ name: v.metadata.name,
126
+ title: v.spec.title ?? null,
127
+ from: v.spec.from ?? null,
128
+ params: v.spec.params ?? null,
129
+ fields: v.spec.fields ?? null,
130
+ list: checkViewAdminUi(v).list,
131
+ }));
132
+ const guarded = (method, path, body) => {
133
+ app.on(method.toUpperCase(), path, async (c) => {
134
+ const gate = await readStaffGate(c, auth);
135
+ if (gate.kind === "unauth")
136
+ return adminUnauthenticated(c, path);
137
+ if (gate.kind === "forbidden")
138
+ return adminNotStaff(c, path, gate.login);
139
+ return body(c, gate);
140
+ });
141
+ };
142
+ const roleGuarded = (method, path, minimumRole, body) => {
143
+ guarded(method, path, (c, gate) => {
144
+ if (!meetsRole(gate.role, minimumRole)) {
145
+ return adminInsufficientRole(c, path, minimumRole);
146
+ }
147
+ return body(c, gate);
148
+ });
149
+ };
150
+ guarded("get", "/admin/api/me", (_c, gate) => Response.json({ login: gate.login, role: gate.role, userId: gate.userId, image: gate.image }));
151
+ roleGuarded("get", "/admin/api/staff", "owner", async () => Response.json({ users: await auth.listUsers() }));
152
+ roleGuarded("get", "/admin/api/members", "editor", async (c) => {
153
+ const rawLimit = c.req.query("limit");
154
+ const limit = rawLimit === undefined ? 50 : Number(rawLimit);
155
+ const cursor = c.req.query("cursor") || undefined;
156
+ const search = c.req.query("search")?.trim() || undefined;
157
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100 ||
158
+ (cursor && !decodeMemberCursor(cursor)) || (search?.length ?? 0) > 200) {
159
+ return Response.json({
160
+ ok: false,
161
+ diagnostic: runtimeDiagnostic({
162
+ code: "INPUT_VALIDATION_FAILED",
163
+ severity: "error",
164
+ path: "GET /admin/api/members",
165
+ expected: "limit 1..100, a valid cursor, and search up to 200 characters",
166
+ message: "Member list parameters are invalid.",
167
+ }),
168
+ }, { status: 400 });
169
+ }
170
+ const result = await auth.listMembers({
171
+ limit,
172
+ search,
173
+ cursor,
174
+ cursorDirection: c.req.query("cursor_direction") === "backward" ? "backward" : "forward",
175
+ });
176
+ return Response.json({
177
+ items: result.items,
178
+ previous_cursor: result.previousCursor,
179
+ next_cursor: result.nextCursor,
180
+ });
181
+ });
182
+ roleGuarded("patch", "/admin/api/staff/:id/role", "owner", async (c, gate) => {
183
+ const userId = c.req.param("id") ?? "";
184
+ const body = (await c.req.raw.json().catch(() => ({})));
185
+ const role = body.role === null ? null : typeof body.role === "string" ? body.role : undefined;
186
+ if (role === undefined || (role !== null && !STAFF_ROLE_SET.has(role))) {
187
+ return Response.json({
188
+ ok: false,
189
+ diagnostic: runtimeDiagnostic({
190
+ code: "INPUT_VALIDATION_FAILED",
191
+ severity: "error",
192
+ path: `PATCH /admin/api/staff/:id/role`,
193
+ expected: `body.role in [${[...STAFF_ROLE_SET].join(", ")}] or null`,
194
+ message: "`role` must be a staff role string or null (revoke).",
195
+ }),
196
+ }, { status: 400 });
197
+ }
198
+ // An owner cannot change their own role. Demoting the only owner
199
+ // would lock everyone out of staff management with no SDK-side
200
+ // recovery path (the fix would be a manual D1 UPDATE).
201
+ if (userId === gate.userId) {
202
+ return Response.json({
203
+ ok: false,
204
+ diagnostic: runtimeDiagnostic({
205
+ code: "AUTH_DENIED",
206
+ severity: "error",
207
+ path: `PATCH /admin/api/staff/:id/role`,
208
+ expected: "target user is not the caller",
209
+ message: "You cannot change your own role.",
210
+ }),
211
+ }, { status: 403 });
212
+ }
213
+ const changed = await auth.setUserRole(userId, role);
214
+ if (!changed) {
215
+ return Response.json({
216
+ ok: false,
217
+ diagnostic: runtimeDiagnostic({
218
+ code: "NOT_FOUND",
219
+ severity: "error",
220
+ path: `PATCH /admin/api/staff/:id/role`,
221
+ expected: "an existing user id",
222
+ message: "No user matched that id.",
223
+ }),
224
+ }, { status: 404 });
225
+ }
226
+ return Response.json({ ok: true });
227
+ });
228
+ roleGuarded("post", "/admin/api/staff/invitations", "owner", async (c, gate) => {
229
+ const body = (await c.req.raw.json().catch(() => ({})));
230
+ const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
231
+ const role = typeof body.role === "string" ? body.role : "";
232
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || !STAFF_ROLE_SET.has(role)) {
233
+ return Response.json({
234
+ ok: false,
235
+ diagnostic: runtimeDiagnostic({
236
+ code: "INPUT_VALIDATION_FAILED",
237
+ severity: "error",
238
+ path: "POST /admin/api/staff/invitations",
239
+ expected: `body.email is an address and body.role in [${[...STAFF_ROLE_SET].join(", ")}]`,
240
+ message: "Invitation needs a valid `email` and a staff `role`.",
241
+ }),
242
+ }, { status: 400 });
243
+ }
244
+ const result = await auth.inviteUser(email, role);
245
+ if (result.kind === "exists") {
246
+ if (result.id === gate.userId) {
247
+ return Response.json({
248
+ ok: false,
249
+ diagnostic: runtimeDiagnostic({
250
+ code: "AUTH_DENIED",
251
+ severity: "error",
252
+ path: "POST /admin/api/staff/invitations",
253
+ expected: "an email other than the caller's",
254
+ message: "You cannot change your own role.",
255
+ }),
256
+ }, { status: 403 });
257
+ }
258
+ await auth.setUserRole(result.id, role);
259
+ }
260
+ return Response.json({ ok: true, userId: result.id });
261
+ });
262
+ roleGuarded("delete", "/admin/api/staff/invitations/:id", "owner", async (c) => {
263
+ const revoked = await auth.revokeInvite(c.req.param("id") ?? "");
264
+ if (!revoked) {
265
+ return Response.json({
266
+ ok: false,
267
+ diagnostic: runtimeDiagnostic({
268
+ code: "CONFLICT",
269
+ severity: "error",
270
+ path: "DELETE /admin/api/staff/invitations/:id",
271
+ expected: "an invitation nobody has signed in to",
272
+ message: "Only never-signed-in invitations can be revoked. For an active user, clear the role instead.",
273
+ }),
274
+ }, { status: 409 });
275
+ }
276
+ return Response.json({ ok: true });
277
+ });
278
+ guarded("get", "/admin/api/collections", () => Response.json({ collections }));
279
+ guarded("get", "/admin/api/views-manifest", () => Response.json({ views: viewsManifest }));
280
+ // Staff Views (#433): mounted behind the staff gate at
281
+ // `/admin/api/views/<name>` — NOT on the public `/api/views/<name>`
282
+ // path (the public mount loop skips `surface: staff`). Reuses the
283
+ // exact same `handleViewRequest` logic and response shape as the
284
+ // public surface; only the gate + path differ.
285
+ for (const v of staffViews) {
286
+ const viewName = v.metadata.name;
287
+ guarded("get", `/admin/api/views/${viewName}`, async (c, gate) => {
288
+ const runtime = await ref.get();
289
+ return handleViewRequest(c.req.raw, runtime, v, adminHandlerContext(c, gate, ref));
290
+ });
291
+ guarded("get", `/admin/api/views/${viewName}/export`, async (c, gate) => {
292
+ const runtime = await ref.get();
293
+ return handleViewRequest(c.req.raw, runtime, v, adminHandlerContext(c, gate, ref), true);
294
+ });
295
+ }
296
+ guarded("get", "/admin/api/operations", (c, gate) => Response.json({
297
+ operations: operations.filter((op) => evaluateAuthAll(op.procedure.spec.requires, adminHandlerContext(c, gate, ref), `GET /admin/api/operations/${op.name}`, "runtime") === null).map((op) => ({
298
+ name: op.name,
299
+ title: op.title,
300
+ description: op.description,
301
+ input: op.input,
302
+ uiSchema: op.uiSchema,
303
+ triggers: op.triggers,
304
+ rowBindings: op.rowBindings,
305
+ })),
306
+ }));
307
+ guarded("post", "/admin/api/operations/:name", async (c, gate) => {
308
+ const name = c.req.param("name") ?? "";
309
+ const op = operationsByName.get(name);
310
+ if (!op) {
311
+ return Response.json({
312
+ ok: false,
313
+ diagnostic: runtimeDiagnostic({
314
+ code: "NOT_FOUND",
315
+ severity: "error",
316
+ path: `POST /admin/api/operations/${name}`,
317
+ expected: "a staff-operable Procedure name from GET /admin/api/operations",
318
+ message: `No staff-operable operation named '${name}'.`,
319
+ }),
320
+ }, { status: 404 });
321
+ }
322
+ return runMantleUseCase(`POST /admin/api/operations/${name}`, async () => {
323
+ const runtime = await ref.get();
324
+ const input = (await c.req.raw.json().catch(() => ({})));
325
+ // Reuse the exact use case the staff MCP surface invokes
326
+ // Procedures through (`McpJsonRpcDispatcher.dispatchToolByName`
327
+ // → `runtime.invokeProcedure`) — same auth evaluation,
328
+ // same input/output validation, same handler dispatch. The
329
+ // staff HandlerContext below mirrors the MCP dispatcher's
330
+ // `procCtx` construction 1:1.
331
+ const result = await runtime.invokeProcedure({
332
+ procedure: op.procedure.metadata.name,
333
+ input: objectField(input),
334
+ ctx: adminHandlerContext(c, gate, ref),
335
+ pathPrefix: `POST /admin/api/operations/${name}`,
336
+ });
337
+ if (!result.ok) {
338
+ throw new DiagnosticError(result.diagnostic);
339
+ }
340
+ return { ok: true, output: result.data };
341
+ });
342
+ });
343
+ guarded("get", "/admin/api/site", async (c) => {
344
+ const runtime = await ref.get();
345
+ const { origin, ...site } = await runtime.siteConfig.load();
346
+ const publicUrl = origin || new URL(c.req.url).origin;
347
+ return Response.json({
348
+ ...site,
349
+ publicUrl,
350
+ mcpUrl: `${publicUrl}/mcp/staff`,
351
+ });
352
+ });
353
+ roleGuarded("get", "/admin/api/site-settings", "owner", async () => runMantleUseCase("GET /admin/api/site-settings", async () => {
354
+ const runtime = await ref.get();
355
+ return adminSiteSettings(await runtime.siteConfig.load());
356
+ }));
357
+ roleGuarded("patch", "/admin/api/site-settings", "owner", async (c) => runMantleUseCase("PATCH /admin/api/site-settings", async () => {
358
+ const runtime = await ref.get();
359
+ const body = (await c.req.raw.json().catch(() => ({})));
360
+ const site = await runtime.updateSiteSettings.execute({
361
+ brand: stringField(body.brand),
362
+ title: stringField(body.title),
363
+ description: stringField(body.description),
364
+ ga4MeasurementId: stringField(body.ga4MeasurementId),
365
+ facebookPixelId: stringField(body.facebookPixelId),
366
+ });
367
+ return adminSiteSettings(site);
368
+ }));
369
+ guarded("get", "/admin/api/entries", async (c) => {
370
+ const collection = c.req.query("collection");
371
+ if (!collection) {
372
+ return Response.json({
373
+ ok: false,
374
+ diagnostic: runtimeDiagnostic({
375
+ code: "INPUT_VALIDATION_FAILED",
376
+ severity: "error",
377
+ path: "GET /admin/api/entries",
378
+ expected: "?collection=<name> query parameter",
379
+ message: "Missing `collection` query parameter.",
380
+ }),
381
+ }, { status: 400 });
382
+ }
383
+ const runtime = await ref.get();
384
+ const rawLimit = c.req.query("limit");
385
+ const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : NaN;
386
+ const statusQuery = c.req.query("status");
387
+ const sortDirection = c.req.query("direction") === "asc" ? "asc" : "desc";
388
+ const filterField = c.req.query("filter_field");
389
+ const filterValue = c.req.query("filter_value");
390
+ if (Boolean(filterField) !== Boolean(filterValue)) {
391
+ return Response.json({
392
+ ok: false,
393
+ diagnostic: runtimeDiagnostic({
394
+ code: "INPUT_VALIDATION_FAILED",
395
+ severity: "error",
396
+ path: "GET /admin/api/entries",
397
+ expected: "filter_field and filter_value together",
398
+ message: "List filters require both `filter_field` and `filter_value`.",
399
+ }),
400
+ }, { status: 400 });
401
+ }
402
+ // Admin pagination needs the cursored shape — `executePage` returns
403
+ // `{ rows, nextCursor? }`. `execute()` is the flat-array variant
404
+ // for app code.
405
+ const result = await runtime.listEntries.executePage({
406
+ collection,
407
+ status: statusQuery && statusQuery !== "all" ? statusQuery : undefined,
408
+ limit: Number.isFinite(parsedLimit) ? parsedLimit : 99,
409
+ cursor: c.req.query("cursor") ?? undefined,
410
+ cursorDirection: c.req.query("cursor_direction") === "backward" ? "backward" : "forward",
411
+ search: c.req.query("search") || undefined,
412
+ filter: filterField && filterValue
413
+ ? { field: filterField, value: filterValue }
414
+ : undefined,
415
+ sort: {
416
+ field: c.req.query("sort") || "updatedAt",
417
+ direction: sortDirection,
418
+ },
419
+ });
420
+ const translationLocales = new Map();
421
+ const translationSchemas = schemas.filter((schema) => schema.spec.translates?.parent === collection);
422
+ for (const schema of translationSchemas) {
423
+ const field = schema.spec.translates.on;
424
+ const parentValues = new Map();
425
+ for (const row of result.rows) {
426
+ const value = primitiveJoinValue(row.data[field]);
427
+ if (value !== null)
428
+ parentValues.set(joinValueKey(value), value);
429
+ }
430
+ const translations = await runtime.entries.readByDataFieldIn({
431
+ collection: schema.metadata.name,
432
+ field,
433
+ values: [...parentValues.values()],
434
+ });
435
+ const localesByValue = new Map();
436
+ for (const entry of translations) {
437
+ const value = primitiveJoinValue(entry.data[field]);
438
+ if (value === null || !entry.locale)
439
+ continue;
440
+ const key = joinValueKey(value);
441
+ const locales = localesByValue.get(key) ?? new Set();
442
+ locales.add(entry.locale);
443
+ localesByValue.set(key, locales);
444
+ }
445
+ for (const row of result.rows) {
446
+ const value = primitiveJoinValue(row.data[field]);
447
+ if (value === null)
448
+ continue;
449
+ const locales = translationLocales.get(row.id) ?? new Set();
450
+ for (const locale of localesByValue.get(joinValueKey(value)) ?? [])
451
+ locales.add(locale);
452
+ translationLocales.set(row.id, locales);
453
+ }
454
+ }
455
+ const items = result.rows.map((row) => adminListItem(row, schemasByName, [...(translationLocales.get(row.id) ?? [])]));
456
+ return Response.json({
457
+ items,
458
+ previous_cursor: result.previousCursor ?? null,
459
+ next_cursor: result.nextCursor ?? null,
460
+ });
461
+ });
462
+ guarded("get", "/admin/api/entries/export", async (c) => {
463
+ const collection = c.req.query("collection");
464
+ const schema = collection ? schemasByName.get(collection) : undefined;
465
+ if (!collection || !schema) {
466
+ return Response.json({
467
+ ok: false,
468
+ diagnostic: runtimeDiagnostic({
469
+ code: "NOT_FOUND",
470
+ severity: "error",
471
+ path: "GET /admin/api/entries/export",
472
+ expected: "?collection=<name> naming a declared Schema",
473
+ message: collection
474
+ ? `Schema '${collection}' was not found.`
475
+ : "Missing `collection` query parameter.",
476
+ }),
477
+ }, { status: 404 });
478
+ }
479
+ const runtime = await ref.get();
480
+ const statusQuery = c.req.query("status");
481
+ const filterField = c.req.query("filter_field");
482
+ const filterValue = c.req.query("filter_value");
483
+ if (Boolean(filterField) !== Boolean(filterValue)) {
484
+ return Response.json({
485
+ ok: false,
486
+ diagnostic: runtimeDiagnostic({
487
+ code: "INPUT_VALIDATION_FAILED",
488
+ severity: "error",
489
+ path: "GET /admin/api/entries/export",
490
+ expected: "filter_field and filter_value together",
491
+ message: "List filters require both `filter_field` and `filter_value`.",
492
+ }),
493
+ }, { status: 400 });
494
+ }
495
+ const listOptions = {
496
+ collection,
497
+ status: statusQuery && statusQuery !== "all" ? statusQuery : undefined,
498
+ search: c.req.query("search") || undefined,
499
+ filter: filterField && filterValue ? { field: filterField, value: filterValue } : undefined,
500
+ sort: {
501
+ field: c.req.query("sort") || "updatedAt",
502
+ direction: c.req.query("direction") === "asc" ? "asc" : "desc",
503
+ },
504
+ };
505
+ const propertyNames = Object.keys(schema.spec.schema.properties ?? {});
506
+ const columns = ["id", "status", "version", "updated_at", ...propertyNames];
507
+ let page = await runtime.listEntries.executePage({ ...listOptions, limit: 100 });
508
+ async function* chunks() {
509
+ while (true) {
510
+ if (page.rows.length > 0) {
511
+ yield page.rows.map((row) => csvRow(columns.map((column) => csvValue(column, row, propertyNames)))).join("\r\n") + "\r\n";
512
+ }
513
+ if (!page.nextCursor)
514
+ return;
515
+ page = await runtime.listEntries.executePage({
516
+ ...listOptions,
517
+ limit: 100,
518
+ cursor: page.nextCursor,
519
+ });
520
+ }
521
+ }
522
+ return csvDownloadResponse(collection, columns, chunks());
523
+ });
524
+ guarded("get", "/admin/api/entries/:id", async (c) => runMantleUseCase(`GET /admin/api/entries/${c.req.param("id")}`, async () => {
525
+ const runtime = await ref.get();
526
+ const id = c.req.param("id");
527
+ const row = await runtime.getEntry.execute({ id });
528
+ return entryEditorPayload(runtime, row, schemas);
529
+ }));
530
+ guarded("post", "/admin/api/entries", async (c, gate) => runMantleUseCase("POST /admin/api/entries", async () => {
531
+ const runtime = await ref.get();
532
+ const body = (await c.req.raw.json().catch(() => ({})));
533
+ if (typeof body.collection !== "string" || !body.collection) {
534
+ throw new DiagnosticError(runtimeDiagnostic({
535
+ code: "INPUT_VALIDATION_FAILED",
536
+ severity: "error",
537
+ path: "POST /admin/api/entries#/collection",
538
+ expected: "non-empty string",
539
+ message: "A non-empty `collection` is required.",
540
+ }));
541
+ }
542
+ assertMutableSchema("POST /admin/api/entries", schemasByName.get(body.collection));
543
+ if (gate.role === "contributor" &&
544
+ (schemasByName.get(body.collection)?.spec.lifecycle ?? "publishing") === "operational") {
545
+ throw new DiagnosticError(adminRoleDiagnostic("POST /admin/api/entries", "editor", "Contributors can create drafts, not operational records."));
546
+ }
547
+ const row = await runtime.createDraft.execute({
548
+ collection: body.collection,
549
+ data: objectField(body.data),
550
+ authorId: gate.userId,
551
+ ctx: adminHandlerContext(c, gate, ref),
552
+ originalInput: body,
553
+ });
554
+ return entryEditorPayload(runtime, row, schemas);
555
+ }));
556
+ guarded("patch", "/admin/api/entries/:id", async (c, gate) => runMantleUseCase(`PATCH /admin/api/entries/${c.req.param("id")}`, async () => {
557
+ const runtime = await ref.get();
558
+ const id = c.req.param("id");
559
+ const current = await readMutableEntry(runtime, id, `PATCH /admin/api/entries/${id}`);
560
+ if (gate.role === "contributor") {
561
+ const lifecycle = schemasByName.get(current.collection)?.spec.lifecycle ?? "publishing";
562
+ if (lifecycle === "operational" || current.status !== "draft") {
563
+ throw new DiagnosticError(adminRoleDiagnostic(`PATCH /admin/api/entries/${id}`, "editor", "Contributors can edit drafts only."));
564
+ }
565
+ }
566
+ const body = (await c.req.raw.json().catch(() => ({})));
567
+ const updated = await runtime.updateDraft.execute({
568
+ id,
569
+ expectedVersion: expectedVersionField(body.expectedVersion, `PATCH /admin/api/entries/${id}#/expectedVersion`),
570
+ data: objectField(body.data),
571
+ ctx: adminHandlerContext(c, gate, ref),
572
+ originalInput: body,
573
+ });
574
+ return entryEditorPayload(runtime, updated, schemas);
575
+ }));
576
+ roleGuarded("post", "/admin/api/entries/:id/publish", "editor", async (c, gate) => runMantleUseCase(`POST /admin/api/entries/${c.req.param("id")}/publish`, async () => {
577
+ const runtime = await ref.get();
578
+ const id = c.req.param("id");
579
+ await readMutableEntry(runtime, id, `POST /admin/api/entries/${id}/publish`);
580
+ const body = await c.req.raw.json().catch(() => ({}));
581
+ const row = await runtime.requestPublish.execute({
582
+ id,
583
+ ctx: adminHandlerContext(c, gate, ref),
584
+ originalInput: body,
585
+ });
586
+ return entryEditorPayload(runtime, row, schemas);
587
+ }));
588
+ roleGuarded("post", "/admin/api/entries/:id/unpublish", "editor", async (c, gate) => runMantleUseCase(`POST /admin/api/entries/${c.req.param("id")}/unpublish`, async () => {
589
+ const runtime = await ref.get();
590
+ const id = c.req.param("id");
591
+ await readMutableEntry(runtime, id, `POST /admin/api/entries/${id}/unpublish`);
592
+ const body = await c.req.raw.json().catch(() => ({}));
593
+ const row = await runtime.unpublish.execute({
594
+ id,
595
+ ctx: adminHandlerContext(c, gate, ref),
596
+ originalInput: body,
597
+ });
598
+ return entryEditorPayload(runtime, row, schemas);
599
+ }));
600
+ roleGuarded("delete", "/admin/api/entries/:id", "editor", async (c, gate) => runMantleUseCase(`DELETE /admin/api/entries/${c.req.param("id")}`, async () => {
601
+ const runtime = await ref.get();
602
+ const id = c.req.param("id");
603
+ await readMutableEntry(runtime, id, `DELETE /admin/api/entries/${id}`);
604
+ const body = await c.req.raw.json().catch(() => ({}));
605
+ return runtime.deleteEntry.execute({
606
+ id,
607
+ ctx: adminHandlerContext(c, gate, ref),
608
+ originalInput: body,
609
+ });
610
+ }));
611
+ // Two-step multi-variant direct-upload flow (#272):
612
+ // POST /uploads (variants manifest) → caller PUTs every variant
613
+ // directly to R2 S3 (Worker bypassed) → POST /uploads/:groupId/commit.
614
+ const MEDIA_UPLOADS_PATH = "/admin/api/media/uploads";
615
+ const MEDIA_COMMIT_PATH = "/admin/api/media/uploads/:uploadGroupId/commit";
616
+ roleGuarded("post", MEDIA_UPLOADS_PATH, "editor", async (c) => {
617
+ const runtime = await ref.get();
618
+ const media = runtime.media;
619
+ if (!media)
620
+ return mediaNotConfiguredResponse(`POST ${MEDIA_UPLOADS_PATH}`);
621
+ const body = (await c.req.raw.json().catch(() => ({})));
622
+ if (typeof body.filename !== "string" ||
623
+ typeof body.purpose !== "string" ||
624
+ !Array.isArray(body.variants)) {
625
+ return Response.json({
626
+ ok: false,
627
+ diagnostic: runtimeDiagnostic({
628
+ code: "INPUT_VALIDATION_FAILED",
629
+ severity: "error",
630
+ path: `POST ${MEDIA_UPLOADS_PATH}`,
631
+ expected: "{ filename: string, purpose: string, variants: [{ mimeType, byteSize, role }, ...] }",
632
+ }),
633
+ }, { status: 400 });
634
+ }
635
+ const variants = [];
636
+ for (const raw of body.variants) {
637
+ if (raw === null || typeof raw !== "object") {
638
+ return Response.json({
639
+ ok: false,
640
+ diagnostic: runtimeDiagnostic({
641
+ code: "INPUT_VALIDATION_FAILED",
642
+ severity: "error",
643
+ path: `POST ${MEDIA_UPLOADS_PATH}`,
644
+ expected: "variants[] entries are objects with { mimeType, byteSize, role }",
645
+ }),
646
+ }, { status: 400 });
647
+ }
648
+ const v = raw;
649
+ const mimeType = v["mimeType"];
650
+ const byteSize = v["byteSize"];
651
+ const role = v["role"];
652
+ if (typeof mimeType !== "string" ||
653
+ typeof byteSize !== "number" ||
654
+ !Number.isSafeInteger(byteSize) ||
655
+ byteSize <= 0 ||
656
+ (role !== "primary" && role !== "alternate" && role !== "fallback")) {
657
+ return Response.json({
658
+ ok: false,
659
+ diagnostic: runtimeDiagnostic({
660
+ code: "INPUT_VALIDATION_FAILED",
661
+ severity: "error",
662
+ path: `POST ${MEDIA_UPLOADS_PATH}`,
663
+ expected: "each variant: { mimeType: string, byteSize: positive integer, role: 'primary'|'alternate'|'fallback' }",
664
+ }),
665
+ }, { status: 400 });
666
+ }
667
+ variants.push({ mimeType, byteSize, role });
668
+ }
669
+ const { filename, purpose } = body;
670
+ return runMantleUseCase(`POST ${MEDIA_UPLOADS_PATH}`, () => media.createUpload.execute({
671
+ filename,
672
+ purpose,
673
+ variants,
674
+ alt: typeof body.alt === "string" ? body.alt : undefined,
675
+ caption: typeof body.caption === "string" ? body.caption : undefined,
676
+ }));
677
+ });
678
+ roleGuarded("post", MEDIA_COMMIT_PATH, "editor", async (c) => {
679
+ const runtime = await ref.get();
680
+ const media = runtime.media;
681
+ if (!media)
682
+ return mediaNotConfiguredResponse(`POST ${MEDIA_COMMIT_PATH}`);
683
+ // Hono only invokes this handler when the route matched, so the
684
+ // path param is always present at runtime.
685
+ const uploadGroupId = c.req.param("uploadGroupId");
686
+ const body = (await c.req.raw.json().catch(() => ({})));
687
+ return runMantleUseCase(`POST ${MEDIA_COMMIT_PATH}`, () => media.commitUpload.execute({
688
+ uploadGroupId,
689
+ alt: typeof body.alt === "string" ? body.alt : undefined,
690
+ caption: typeof body.caption === "string" ? body.caption : undefined,
691
+ }));
692
+ });
693
+ // Media library (#434): list / get / patch / delete over committed
694
+ // assets. All staff-gated; all 501 + MEDIA_NOT_CONFIGURED when no
695
+ // `mediaStorage` is bound (mirrors the upload handlers above).
696
+ const MEDIA_LIST_PATH = "/admin/api/media";
697
+ const MEDIA_ASSET_PATH = "/admin/api/media/:id";
698
+ roleGuarded("get", MEDIA_LIST_PATH, "editor", async (c) => {
699
+ const runtime = await ref.get();
700
+ const media = runtime.media;
701
+ if (!media)
702
+ return mediaNotConfiguredResponse(`GET ${MEDIA_LIST_PATH}`);
703
+ const rawLimit = c.req.query("limit");
704
+ const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : NaN;
705
+ return runMantleUseCase(`GET ${MEDIA_LIST_PATH}`, async () => {
706
+ const result = await media.listAssets.execute({
707
+ limit: Number.isFinite(parsedLimit) ? parsedLimit : undefined,
708
+ cursor: c.req.query("cursor") ?? undefined,
709
+ search: c.req.query("search") || undefined,
710
+ });
711
+ return {
712
+ items: result.rows.map(adminMediaItem),
713
+ next_cursor: result.nextCursor ?? null,
714
+ };
715
+ });
716
+ });
717
+ roleGuarded("get", MEDIA_ASSET_PATH, "editor", async (c) => {
718
+ const runtime = await ref.get();
719
+ const media = runtime.media;
720
+ if (!media)
721
+ return mediaNotConfiguredResponse(`GET ${MEDIA_ASSET_PATH}`);
722
+ const id = c.req.param("id");
723
+ return runMantleUseCase(`GET ${MEDIA_ASSET_PATH}`, async () => adminMediaItem(await media.getAsset.execute(id)));
724
+ });
725
+ roleGuarded("patch", MEDIA_ASSET_PATH, "editor", async (c) => {
726
+ const runtime = await ref.get();
727
+ const media = runtime.media;
728
+ if (!media)
729
+ return mediaNotConfiguredResponse(`PATCH ${MEDIA_ASSET_PATH}`);
730
+ const id = c.req.param("id");
731
+ const body = (await c.req.raw.json().catch(() => ({})));
732
+ if ((body.alt !== undefined && typeof body.alt !== "string") ||
733
+ (body.caption !== undefined && typeof body.caption !== "string")) {
734
+ return Response.json({
735
+ ok: false,
736
+ diagnostic: runtimeDiagnostic({
737
+ code: "INPUT_VALIDATION_FAILED",
738
+ severity: "error",
739
+ path: `PATCH ${MEDIA_ASSET_PATH}`,
740
+ expected: "{ alt?: string, caption?: string }",
741
+ message: "`alt` and `caption` must be strings when present.",
742
+ }),
743
+ }, { status: 400 });
744
+ }
745
+ return runMantleUseCase(`PATCH ${MEDIA_ASSET_PATH}`, async () => adminMediaItem(await media.updateAsset.execute({
746
+ id,
747
+ alt: typeof body.alt === "string" ? body.alt : undefined,
748
+ caption: typeof body.caption === "string" ? body.caption : undefined,
749
+ })));
750
+ });
751
+ roleGuarded("delete", MEDIA_ASSET_PATH, "editor", async (c) => {
752
+ const runtime = await ref.get();
753
+ const media = runtime.media;
754
+ if (!media)
755
+ return mediaNotConfiguredResponse(`DELETE ${MEDIA_ASSET_PATH}`);
756
+ const id = c.req.param("id");
757
+ return runMantleUseCase(`DELETE ${MEDIA_ASSET_PATH}`, () => media.deleteAsset.execute(id));
758
+ });
759
+ }
760
+ /** Shape a committed `MediaAsset` for the admin media library wire
761
+ * surface (#434): the full variants set plus a convenience
762
+ * `primaryUrl` / `mime` / `byteSize` lifted off the primary variant so
763
+ * the SPA grid can render a thumbnail without re-deriving it. */
764
+ function adminMediaItem(asset) {
765
+ const primary = asset.variants.find((v) => v.role === "primary") ?? asset.variants[0] ?? null;
766
+ return {
767
+ id: asset.id,
768
+ variants: asset.variants,
769
+ primaryUrl: primary?.publicUrl ?? null,
770
+ mime: primary?.mimeType ?? null,
771
+ byteSize: primary?.byteSize ?? null,
772
+ alt: asset.alt ?? null,
773
+ caption: asset.caption ?? null,
774
+ createdAt: asset.createdAt,
775
+ };
776
+ }
777
+ /**
778
+ * Derivation rule (#426, manifest-only, no new grammar): a Procedure
779
+ * is staff-operable iff some Trigger targets it with either
780
+ * (a) `source.kind: "mcp"` and `source.surface === "staff"` — the
781
+ * exact predicate `collectMcpProcedures` in `mountMcp.ts` uses
782
+ * to build the `/mcp/staff` tool catalog, or
783
+ * (b) `source.kind: "http"` AND the procedure's `spec.requires?.auth`
784
+ * predicates include a `ctx.staff` entry.
785
+ *
786
+ * `triggers` on the result lists every distinct kind ("mcp" | "http")
787
+ * that qualified the procedure, in Trigger declaration order,
788
+ * deduplicated — a Procedure can be both an MCP staff tool and an
789
+ * HTTP Trigger simultaneously.
790
+ *
791
+ * `title`/`description` (#430) are raw passthroughs of
792
+ * `Procedure.spec.title`/`.description` — this REPLACES the pre-#430
793
+ * hack of reading `procedure.spec.input.description` as a fake
794
+ * "description". The wire shape (`string | LocalizedText | null`)
795
+ * stays compatible for plain-string manifests since a string
796
+ * title/description round-trips as a string; the SPA resolves
797
+ * locale-map values client-side.
798
+ */
799
+ function discoverStaffOperations(plan, schemasByName) {
800
+ const procedures = new Map(Object.values(plan.procedures)
801
+ .map(({ manifest }) => [manifest.metadata.name, manifest]));
802
+ const triggerKindsByProcedure = new Map();
803
+ for (const { manifest: m } of Object.values(plan.triggers)) {
804
+ const source = m.spec.source;
805
+ const procedureName = m.spec.target.procedure;
806
+ const procedure = procedures.get(procedureName);
807
+ if (!procedure)
808
+ continue;
809
+ const isStaffMcp = source.kind === "mcp" && source.surface === "staff";
810
+ const isStaffHttp = source.kind === "http" && hasCtxStaffPredicate(procedure);
811
+ if (!isStaffMcp && !isStaffHttp)
812
+ continue;
813
+ const kind = source.kind === "mcp" ? "mcp" : "http";
814
+ const set = triggerKindsByProcedure.get(procedureName) ?? new Set();
815
+ set.add(kind);
816
+ triggerKindsByProcedure.set(procedureName, set);
817
+ }
818
+ const out = [];
819
+ for (const [name, kinds] of triggerKindsByProcedure) {
820
+ const procedure = procedures.get(name);
821
+ if (!procedure)
822
+ continue;
823
+ out.push({
824
+ name,
825
+ title: procedure.spec.title ?? null,
826
+ description: procedure.spec.description ?? null,
827
+ input: procedure.spec.input,
828
+ uiSchema: procedure.spec.uiSchema ?? null,
829
+ triggers: [...kinds],
830
+ rowBindings: discoverRowBindings(procedure, schemasByName),
831
+ procedure,
832
+ });
833
+ }
834
+ return out;
835
+ }
836
+ /**
837
+ * Row-action bindings (#430): which `x-mantle-ref` input properties on
838
+ * a staff-operable Procedure point at a real, non-"translates"
839
+ * collection, so the admin SPA can offer this operation from a row's
840
+ * "⋯" menu on that collection's table (with the ref field pre-filled
841
+ * and read-only).
842
+ *
843
+ * Skip conditions (no binding produced, no error):
844
+ * - `input.type !== "object"` or `input.properties` absent — nothing
845
+ * to walk.
846
+ * - the `x-mantle-ref` target name isn't in `schemasByName` (unknown
847
+ * collection).
848
+ * - the target Schema has `spec.translates` set (it's a translation
849
+ * child, not a real top-level collection).
850
+ *
851
+ * `rowField` derivation: if the target Schema's `spec.uniqueIndexes`
852
+ * has EXACTLY one entry and that entry names EXACTLY one field, use
853
+ * that field name (e.g. `sku`). In every other case (no unique
854
+ * indexes, more than one, or a composite/multi-field index) fall back
855
+ * to the reserved `id` column.
856
+ */
857
+ function discoverRowBindings(procedure, schemasByName) {
858
+ const input = procedure.spec.input;
859
+ const inputTypes = Array.isArray(input.type) ? input.type : input.type ? [input.type] : [];
860
+ if (input.type !== undefined && !inputTypes.includes("object"))
861
+ return [];
862
+ const properties = input.properties;
863
+ if (!properties)
864
+ return [];
865
+ const bindings = [];
866
+ for (const [propertyName, propertySchema] of Object.entries(properties)) {
867
+ const refTarget = propertySchema[MANTLE_REF_KEYWORD];
868
+ if (typeof refTarget !== "string" || refTarget.length === 0)
869
+ continue;
870
+ const targetSchema = schemasByName.get(refTarget);
871
+ if (!targetSchema)
872
+ continue;
873
+ if (targetSchema.spec.translates)
874
+ continue;
875
+ bindings.push({
876
+ collection: refTarget,
877
+ inputField: propertyName,
878
+ // Same-name wins: when the target Schema declares a property
879
+ // with the input field's own name (skuCode → product-skus.
880
+ // skuCode, orderNumber → orders.orderNumber), that IS the value
881
+ // the input wants — prefilling the entry id there would feed
882
+ // the wrong value. Only refs with no same-name property fall
883
+ // back to the single-field unique index and finally the
884
+ // reserved id column (tierId-style inputs, where the id
885
+ // genuinely is the value).
886
+ rowField: sameNameField(propertyName, targetSchema)
887
+ ?? singleUniqueIndexField(targetSchema)
888
+ ?? "id",
889
+ });
890
+ }
891
+ return bindings;
892
+ }
893
+ /** `inputField` itself, when the target Schema declares a property of
894
+ * that name; else `null`. */
895
+ function sameNameField(inputField, schema) {
896
+ const properties = schema.spec.schema.properties ?? {};
897
+ return inputField in properties ? inputField : null;
898
+ }
899
+ /** The lone field name of a Schema's single-field unique index, or
900
+ * `null` when `uniqueIndexes` is absent, empty, has more than one
901
+ * entry, or its one entry is a composite (multi-field) index. */
902
+ function singleUniqueIndexField(schema) {
903
+ const uniqueIndexes = schema.spec.uniqueIndexes;
904
+ if (!uniqueIndexes || uniqueIndexes.length !== 1)
905
+ return null;
906
+ const [onlyIndex] = uniqueIndexes;
907
+ if (!onlyIndex || onlyIndex.length !== 1)
908
+ return null;
909
+ return onlyIndex[0] ?? null;
910
+ }
911
+ function hasCtxStaffPredicate(procedure) {
912
+ const predicates = procedure.spec.requires?.auth?.all ?? [];
913
+ return predicates.some((pred) => typeof pred === "object" && pred !== null && "ctx.staff" in pred);
914
+ }
915
+ function adminEntryTitle(data, schema) {
916
+ const key = titleFieldKey(data, schema);
917
+ return key ? data[key] : null;
918
+ }
919
+ /** Which data key publishing-list `adminEntryTitle` reads. */
920
+ function titleFieldKey(data, schema) {
921
+ if (typeof data.title === "string" && data.title)
922
+ return "title";
923
+ if (typeof data.name === "string" && data.name)
924
+ return "name";
925
+ if (typeof data.slug === "string" && data.slug)
926
+ return "slug";
927
+ // Manifest-driven fallback: walk the schema's required properties in
928
+ // declaration order and use the first string-typed one with a
929
+ // non-empty value. Mirrors the admin SPA's `entryTitle` rule.
930
+ if (schema) {
931
+ const properties = schema.properties ?? {};
932
+ for (const key of schema.required ?? []) {
933
+ const fieldSchema = properties[key];
934
+ if (!fieldSchema || !isStringTypedSchema(fieldSchema))
935
+ continue;
936
+ const value = data[key];
937
+ if (typeof value === "string" && value)
938
+ return key;
939
+ }
940
+ }
941
+ return null;
942
+ }
943
+ /** Operational previews contain exactly the manifest-declared list
944
+ * fields. Undeclared lists stay metadata-only. */
945
+ function adminDataPreview(data, manifest) {
946
+ if (!manifest || manifest.spec.lifecycle !== "operational")
947
+ return undefined;
948
+ const list = checkSchemaAdminUi(manifest).list;
949
+ const fields = [...(list.primaryField ? [list.primaryField] : []), ...list.columns];
950
+ if (fields.length === 0)
951
+ return undefined;
952
+ const preview = {};
953
+ for (const key of fields)
954
+ preview[key] = data[key];
955
+ return preview;
956
+ }
957
+ function isStringTypedSchema(schema) {
958
+ const rawType = schema.type;
959
+ const types = Array.isArray(rawType) ? rawType : rawType ? [rawType] : [];
960
+ return types.includes("string");
961
+ }
962
+ function adminListItem(row, schemasByName, translationLocales = []) {
963
+ const manifest = schemasByName.get(row.collection);
964
+ return {
965
+ id: row.id,
966
+ collection: row.collection,
967
+ locale: row.locale ?? null,
968
+ status: row.status,
969
+ version: row.version,
970
+ title: manifest?.spec.lifecycle === "operational"
971
+ ? null
972
+ : adminEntryTitle(row.data, manifest?.spec.schema),
973
+ updated_at: row.updatedAt,
974
+ translation_locales: translationLocales,
975
+ data_preview: adminDataPreview(row.data, manifest),
976
+ };
977
+ }
978
+ /** RFC 4180 field quoting: quote whenever the value contains a comma,
979
+ * quote, or newline; escape embedded quotes by doubling them. */
980
+ function csvField(value) {
981
+ if (/[",\r\n]/.test(value))
982
+ return `"${value.replace(/"/g, '""')}"`;
983
+ return value;
984
+ }
985
+ function csvRow(fields) {
986
+ return fields.map(csvField).join(",");
987
+ }
988
+ function csvDownloadResponse(filename, columns, chunks) {
989
+ const encoder = new TextEncoder();
990
+ const iterator = chunks[Symbol.asyncIterator]();
991
+ const body = new ReadableStream({
992
+ start(controller) {
993
+ controller.enqueue(encoder.encode(`${csvRow(columns)}\r\n`));
994
+ },
995
+ async pull(controller) {
996
+ const next = await iterator.next();
997
+ if (next.done)
998
+ controller.close();
999
+ else
1000
+ controller.enqueue(encoder.encode(next.value));
1001
+ },
1002
+ async cancel() {
1003
+ await iterator.return?.();
1004
+ },
1005
+ });
1006
+ return new Response(body, {
1007
+ status: 200,
1008
+ headers: {
1009
+ "content-type": "text/csv; charset=utf-8",
1010
+ "content-disposition": `attachment; filename="${filename}.csv"`,
1011
+ },
1012
+ });
1013
+ }
1014
+ function viewCsvValue(value) {
1015
+ if (value === undefined || value === null)
1016
+ return "";
1017
+ if (typeof value === "string")
1018
+ return value;
1019
+ if (typeof value === "number" || typeof value === "boolean")
1020
+ return String(value);
1021
+ return JSON.stringify(value);
1022
+ }
1023
+ /** Reads one CSV column's value off an entry row. The four leading
1024
+ * columns are row metadata; everything else is a Schema property
1025
+ * read from `data`. Non-scalar values (objects, arrays) are
1026
+ * JSON-stringified. */
1027
+ function csvValue(column, row, propertyNames) {
1028
+ if (column === "id")
1029
+ return row.id;
1030
+ if (column === "status")
1031
+ return row.status;
1032
+ if (column === "version")
1033
+ return String(row.version);
1034
+ if (column === "updated_at")
1035
+ return String(row.updatedAt);
1036
+ if (!propertyNames.includes(column))
1037
+ return "";
1038
+ const value = row.data[column];
1039
+ if (value === undefined || value === null)
1040
+ return "";
1041
+ if (typeof value === "string")
1042
+ return value;
1043
+ if (typeof value === "number" || typeof value === "boolean")
1044
+ return String(value);
1045
+ return JSON.stringify(value);
1046
+ }
1047
+ async function entryEditorPayload(runtime, row, schemas) {
1048
+ const schema = schemas.find((s) => s.metadata.name === row.collection);
1049
+ if (!schema) {
1050
+ throw new DiagnosticError(runtimeDiagnostic({
1051
+ code: "NOT_FOUND",
1052
+ severity: "error",
1053
+ path: `admin/editor/${row.collection}`,
1054
+ value: row.collection,
1055
+ expected: "declared Schema manifest",
1056
+ message: `Schema '${row.collection}' was not found.`,
1057
+ }));
1058
+ }
1059
+ const related = await relatedEntrySections(runtime, schema, row, schemas);
1060
+ return {
1061
+ collection: adminEditorCollection(schema, schemas),
1062
+ entry: adminEditorEntry(row),
1063
+ parentEntryId: await parentEntryId(runtime, schema, row, schemas),
1064
+ related,
1065
+ };
1066
+ }
1067
+ function adminEditorCollection(schema, schemas) {
1068
+ const adminUi = checkSchemaAdminUi(schema);
1069
+ return {
1070
+ name: schema.metadata.name,
1071
+ title: schema.spec.title,
1072
+ description: schema.spec.description ?? null,
1073
+ lifecycle: schema.spec.lifecycle ?? "publishing",
1074
+ parent: collectionParentFor(schema, schemas),
1075
+ hasTranslations: schemas.some((candidate) => candidate.spec.translates?.parent === schema.metadata.name),
1076
+ localized: schema.spec.localized ?? Boolean(schema.spec.translates),
1077
+ translates: schema.spec.translates ?? null,
1078
+ schema: schema.spec.schema,
1079
+ uiSchema: schema.spec.uiSchema ?? null,
1080
+ mediaFields: mediaFieldsForCollection(schema, schemas),
1081
+ sortableFields: schemaSortableFields(schema),
1082
+ filter: adminUi.filter,
1083
+ list: adminUi.list,
1084
+ };
1085
+ }
1086
+ function adminEditorEntry(row) {
1087
+ return {
1088
+ id: row.id,
1089
+ collection: row.collection,
1090
+ locale: row.locale ?? null,
1091
+ status: row.status,
1092
+ version: row.version,
1093
+ data: row.data,
1094
+ updated_at: row.updatedAt,
1095
+ };
1096
+ }
1097
+ async function relatedEntrySections(runtime, parentSchema, parentRow, schemas) {
1098
+ const relationships = discoverChildRelationships(parentSchema, parentRow, schemas);
1099
+ const sections = [];
1100
+ for (const relationship of relationships) {
1101
+ const childSchema = schemas.find((schema) => schema.metadata.name === relationship.collection);
1102
+ if (!childSchema)
1103
+ continue;
1104
+ const entries = relationship.parentValue === null
1105
+ ? []
1106
+ : await entriesByDataValue(runtime, relationship.collection, relationship.childField, relationship.parentValue);
1107
+ sections.push({
1108
+ collection: adminEditorCollection(childSchema, schemas),
1109
+ relationship: {
1110
+ kind: relationship.kind,
1111
+ parentField: relationship.parentField,
1112
+ childField: relationship.childField,
1113
+ parentValue: relationship.parentValue,
1114
+ },
1115
+ entries: entries.map(adminEditorEntry),
1116
+ });
1117
+ }
1118
+ return sections;
1119
+ }
1120
+ function discoverChildRelationships(parentSchema, parentRow, schemas) {
1121
+ const parentName = parentSchema.metadata.name;
1122
+ const relationships = [];
1123
+ const seen = new Set();
1124
+ const add = (childSchema, kind, parentField, childField, rawParentValue) => {
1125
+ const parentValue = primitiveJoinValue(rawParentValue);
1126
+ if (parentValue == null && kind !== "translation")
1127
+ return;
1128
+ const key = `${childSchema.metadata.name}:${kind}:${parentField}:${childField}:${String(parentValue)}`;
1129
+ if (seen.has(key))
1130
+ return;
1131
+ seen.add(key);
1132
+ relationships.push({
1133
+ collection: childSchema.metadata.name,
1134
+ kind,
1135
+ parentField,
1136
+ childField,
1137
+ parentValue,
1138
+ });
1139
+ };
1140
+ const translates = parentSchema.spec.translates;
1141
+ if (translates) {
1142
+ add(parentSchema, "translation", translates.on, translates.on, parentRow.data[translates.on]);
1143
+ }
1144
+ for (const childSchema of schemas) {
1145
+ if (childSchema.metadata.name === parentName)
1146
+ continue;
1147
+ const childProps = childSchema.spec.schema.properties ?? {};
1148
+ const translates = childSchema.spec.translates;
1149
+ if (translates?.parent === parentName) {
1150
+ add(childSchema, "translation", translates.on, translates.on, parentRow.data[translates.on]);
1151
+ continue;
1152
+ }
1153
+ for (const [childField, childProperty] of Object.entries(childProps)) {
1154
+ if (childProperty[MANTLE_REF_KEYWORD] === parentName) {
1155
+ add(childSchema, "field", "id", childField, parentRow.id);
1156
+ }
1157
+ }
1158
+ }
1159
+ return relationships;
1160
+ }
1161
+ function collectionParentFor(childSchema, schemas) {
1162
+ if (childSchema.spec.translates) {
1163
+ return {
1164
+ collection: childSchema.spec.translates.parent,
1165
+ parentField: childSchema.spec.translates.on,
1166
+ childField: childSchema.spec.translates.on,
1167
+ };
1168
+ }
1169
+ const childProps = childSchema.spec.schema.properties ?? {};
1170
+ // Required ref = composition (the child can't exist without its
1171
+ // parent, so it's buried under the parent in the sidebar). Optional
1172
+ // ref = weak reference — the collection stays top-level.
1173
+ const childRequired = new Set(childSchema.spec.schema.required ?? []);
1174
+ const schemaNames = new Set(schemas
1175
+ .filter((schema) => !schema.spec.translates)
1176
+ .map((schema) => schema.metadata.name));
1177
+ for (const [childField, childProperty] of Object.entries(childProps)) {
1178
+ if (!childRequired.has(childField))
1179
+ continue;
1180
+ const parent = childProperty[MANTLE_REF_KEYWORD];
1181
+ if (typeof parent === "string" && schemaNames.has(parent)) {
1182
+ return { collection: parent, parentField: "id", childField };
1183
+ }
1184
+ }
1185
+ return null;
1186
+ }
1187
+ async function parentEntryId(runtime, childSchema, childRow, schemas) {
1188
+ const parent = collectionParentFor(childSchema, schemas);
1189
+ if (!parent)
1190
+ return null;
1191
+ const value = primitiveJoinValue(childRow.data[parent.childField]);
1192
+ if (value === null)
1193
+ return null;
1194
+ if (parent.parentField === "id" && typeof value === "string")
1195
+ return value;
1196
+ return (await entriesByDataValue(runtime, parent.collection, parent.parentField, value))[0]?.id ?? null;
1197
+ }
1198
+ function primitiveJoinValue(value) {
1199
+ if (typeof value === "string" && value !== "")
1200
+ return value;
1201
+ if (typeof value === "number" && Number.isFinite(value))
1202
+ return value;
1203
+ if (typeof value === "boolean")
1204
+ return value;
1205
+ return null;
1206
+ }
1207
+ function joinValueKey(value) {
1208
+ return `${typeof value}:${String(value)}`;
1209
+ }
1210
+ async function entriesByDataValue(runtime, collection, field, value) {
1211
+ return runtime.entries.findManyByDataField({
1212
+ collection,
1213
+ field,
1214
+ value,
1215
+ limit: 50,
1216
+ });
1217
+ }
1218
+ function adminSiteSettings(site) {
1219
+ return {
1220
+ brand: site.brand,
1221
+ title: site.title,
1222
+ description: site.description,
1223
+ ga4MeasurementId: site.ga4MeasurementId ?? "",
1224
+ facebookPixelId: site.facebookPixelId ?? "",
1225
+ };
1226
+ }
1227
+ function expectedVersionField(value, path) {
1228
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0)
1229
+ return value;
1230
+ throw new DiagnosticError(runtimeDiagnostic({
1231
+ code: "INPUT_VALIDATION_FAILED",
1232
+ severity: "error",
1233
+ path,
1234
+ expected: "non-negative integer",
1235
+ message: "`expectedVersion` is required for draft updates.",
1236
+ }));
1237
+ }
1238
+ function stringField(value) {
1239
+ return typeof value === "string" ? value : undefined;
1240
+ }
1241
+ function objectField(value) {
1242
+ return typeof value === "object" && value !== null && !Array.isArray(value)
1243
+ ? value
1244
+ : {};
1245
+ }
1246
+ function adminHandlerContext(c, gate, ref) {
1247
+ const request = ref.requestContext?.(c);
1248
+ return {
1249
+ user: { id: gate.userId },
1250
+ staff: { id: gate.userId, role: gate.role },
1251
+ auth: {
1252
+ credential: "session",
1253
+ credentialId: gate.sessionId,
1254
+ clientId: null,
1255
+ scopes: [],
1256
+ },
1257
+ env: request?.env ?? {},
1258
+ ...(request?.waitUntil ? { waitUntil: request.waitUntil } : {}),
1259
+ };
1260
+ }
1261
+ async function readStaffGate(c, auth) {
1262
+ const session = await auth.getSession(c.req.raw);
1263
+ if (!session)
1264
+ return { kind: "unauth" };
1265
+ const role = await auth.getUserRole(session.user.id);
1266
+ const login = session.user.githubLogin ?? null;
1267
+ if (!role || !STAFF_ROLE_SET.has(role)) {
1268
+ return { kind: "forbidden", login };
1269
+ }
1270
+ return {
1271
+ kind: "ok",
1272
+ userId: session.user.id,
1273
+ login,
1274
+ image: session.user.image ?? null,
1275
+ role: role,
1276
+ sessionId: session.session.id,
1277
+ };
1278
+ }
1279
+ async function handleViewRequest(req, runtime, view, ctx, exportCsv = false) {
1280
+ const viewName = view.metadata.name;
1281
+ const viewPath = `GET /admin/api/views/${viewName}`;
1282
+ const url = new URL(req.url);
1283
+ const page = parsePositiveInt(url.searchParams.get(PAGE_PARAM));
1284
+ const show = parsePositiveInt(url.searchParams.get(SHOW_PARAM));
1285
+ let params;
1286
+ let listQuery;
1287
+ try {
1288
+ params = coerceViewParams(view, url.searchParams);
1289
+ listQuery = readViewListQuery(view, url.searchParams);
1290
+ }
1291
+ catch (err) {
1292
+ if (err instanceof ViewParamCoercionError) {
1293
+ if (view.spec.requires?.auth) {
1294
+ const denial = evaluateAuthAll(view.spec.requires, ctx, viewPath, "runtime");
1295
+ if (denial) {
1296
+ return Response.json({ ok: false, diagnostic: denial }, { status: HTTP_STATUS_BY_CODE[denial.code] ?? 403 });
1297
+ }
1298
+ }
1299
+ return Response.json({
1300
+ ok: false,
1301
+ diagnostic: runtimeDiagnostic({
1302
+ code: "INPUT_VALIDATION_FAILED",
1303
+ severity: "error",
1304
+ path: viewPath,
1305
+ expected: "query string conforms to View.spec.params",
1306
+ message: err.message,
1307
+ }),
1308
+ }, { status: 400 });
1309
+ }
1310
+ throw err;
1311
+ }
1312
+ const execute = (requestedPage) => runtime.executeView({
1313
+ view: view.metadata.name,
1314
+ pathPrefix: viewPath,
1315
+ options: {
1316
+ params,
1317
+ page: requestedPage,
1318
+ show: exportCsv ? view.spec.limit : show,
1319
+ search: listQuery.search,
1320
+ filters: listQuery.filters,
1321
+ },
1322
+ ctx,
1323
+ });
1324
+ if (exportCsv) {
1325
+ const result = await execute(1);
1326
+ if (!result.ok) {
1327
+ const status = HTTP_STATUS_BY_CODE[result.diagnostic.code] ?? 500;
1328
+ return Response.json({ ok: false, diagnostic: redactForWire(result.diagnostic) }, { status });
1329
+ }
1330
+ const declaredColumns = checkViewAdminUi(view).list.columns;
1331
+ const columns = declaredColumns.length > 0
1332
+ ? [...declaredColumns]
1333
+ : view.spec.fields?.length
1334
+ ? [...view.spec.fields]
1335
+ : [...new Set(result.result.rows.flatMap((row) => Object.keys(row)))];
1336
+ let current = result.result;
1337
+ let exportPage = 1;
1338
+ async function* chunks() {
1339
+ while (true) {
1340
+ if (current.rows.length > 0) {
1341
+ yield current.rows.map((row) => csvRow(columns.map((column) => viewCsvValue(row[column])))).join("\r\n") + "\r\n";
1342
+ }
1343
+ if (current.rows.length < current.show)
1344
+ return;
1345
+ const next = await execute(++exportPage);
1346
+ if (!next.ok)
1347
+ throw new DiagnosticError(next.diagnostic);
1348
+ current = next.result;
1349
+ }
1350
+ }
1351
+ return csvDownloadResponse(viewName, columns, chunks());
1352
+ }
1353
+ const result = await execute(page);
1354
+ if (result.ok) {
1355
+ return Response.json({ ok: true, data: result.result });
1356
+ }
1357
+ const status = HTTP_STATUS_BY_CODE[result.diagnostic.code] ?? 500;
1358
+ // Same wire-redaction contract as the HTTP Trigger egress above (#396).
1359
+ return Response.json({ ok: false, diagnostic: redactForWire(result.diagnostic) }, { status });
1360
+ }
1361
+ function readViewListQuery(view, query) {
1362
+ const list = checkViewAdminUi(view).list;
1363
+ const rawSearch = query.get("search")?.trim() ?? "";
1364
+ if (rawSearch && list.searchFields.length === 0) {
1365
+ throw new ViewParamCoercionError(`View '${view.metadata.name}' does not declare Admin search fields.`);
1366
+ }
1367
+ if (rawSearch.length > 200) {
1368
+ throw new ViewParamCoercionError("View Admin search must be at most 200 characters.");
1369
+ }
1370
+ const allowedFilters = new Set(list.filterFields);
1371
+ for (const key of query.keys()) {
1372
+ if (key.startsWith("filter.") && !allowedFilters.has(key.slice("filter.".length))) {
1373
+ throw new ViewParamCoercionError(`View '${view.metadata.name}' does not declare Admin filter field '${key.slice("filter.".length)}'.`);
1374
+ }
1375
+ }
1376
+ const filters = list.filterFields.flatMap((field) => {
1377
+ const value = query.get(`filter.${field}`)?.trim() ?? "";
1378
+ if (!value)
1379
+ return [];
1380
+ if (value.length > 200) {
1381
+ throw new ViewParamCoercionError(`View Admin filter '${field}' must be at most 200 characters.`);
1382
+ }
1383
+ return [{ field, value }];
1384
+ });
1385
+ return {
1386
+ search: rawSearch ? { term: rawSearch, fields: list.searchFields } : undefined,
1387
+ filters,
1388
+ };
1389
+ }
1390
+ function parsePositiveInt(raw) {
1391
+ if (raw == null || raw === "")
1392
+ return undefined;
1393
+ const n = Number.parseInt(raw, 10);
1394
+ return Number.isFinite(n) && n > 0 ? n : undefined;
1395
+ }
1396
+ function mediaFieldsForCollection(schema, schemas) {
1397
+ const related = [
1398
+ schema,
1399
+ ...schemas.filter((s) => s.spec.translates?.parent === schema.metadata.name),
1400
+ ];
1401
+ const out = [];
1402
+ for (const s of related) {
1403
+ out.push(...mediaFieldsForSchema(s));
1404
+ }
1405
+ return out;
1406
+ }
1407
+ function mediaFieldsForSchema(schema) {
1408
+ const props = schema.spec.schema.properties ?? {};
1409
+ const out = [];
1410
+ for (const [name, prop] of Object.entries(props)) {
1411
+ if (typeof prop !== "object" || prop === null)
1412
+ continue;
1413
+ const hint = prop[MCP_HINT_KEYWORD];
1414
+ if (!isMediaMcpHint(hint))
1415
+ continue;
1416
+ out.push({ name, hint });
1417
+ }
1418
+ return out;
1419
+ }
1420
+ export async function runMantleUseCase(operation, execute) {
1421
+ try {
1422
+ const result = await execute();
1423
+ if (isDiagnosticFailure(result)) {
1424
+ return Response.json({ ok: false, diagnostic: redactForWire(result.diagnostic) }, { status: httpStatusFor(result.diagnostic) });
1425
+ }
1426
+ return Response.json(result);
1427
+ }
1428
+ catch (error) {
1429
+ if (error instanceof DiagnosticError) {
1430
+ return Response.json({ ok: false, diagnostic: redactForWire(error.diagnostic) }, { status: httpStatusFor(error.diagnostic) });
1431
+ }
1432
+ console.error(`[mantle ${operation}] unhandled error`, error);
1433
+ return Response.json({
1434
+ ok: false,
1435
+ diagnostic: runtimeDiagnostic({
1436
+ code: "INTERNAL_ERROR",
1437
+ severity: "error",
1438
+ path: operation,
1439
+ message: "An internal error occurred.",
1440
+ }),
1441
+ }, { status: 500 });
1442
+ }
1443
+ }
1444
+ function isDiagnosticFailure(value) {
1445
+ if (!value || typeof value !== "object")
1446
+ return false;
1447
+ const result = value;
1448
+ if (result.ok !== false || !result.diagnostic || typeof result.diagnostic !== "object") {
1449
+ return false;
1450
+ }
1451
+ const diagnostic = result.diagnostic;
1452
+ return typeof diagnostic.code === "string"
1453
+ && typeof diagnostic.path === "string"
1454
+ && typeof diagnostic.message === "string"
1455
+ && diagnostic.severity === "error";
1456
+ }
1457
+ function adminUnauthenticated(c, path) {
1458
+ return Response.json({
1459
+ ok: false,
1460
+ diagnostic: runtimeDiagnostic({
1461
+ code: "UNAUTHENTICATED",
1462
+ severity: "error",
1463
+ path: `${c.req.method} ${path}`,
1464
+ expected: "active session cookie",
1465
+ message: "Not signed in. Sign in via /admin/sign-in first.",
1466
+ }),
1467
+ }, { status: 401 });
1468
+ }
1469
+ // Distinct from UNAUTHENTICATED so the SPA can render an "access
1470
+ // denied" view for users who DID sign in but lack a staff row,
1471
+ // instead of bouncing them back to /admin/sign-in (which the OAuth
1472
+ // re-auth then silently fast-forwards through, producing a visible
1473
+ // 5-step redirect chain that looks like an infinite loop).
1474
+ function adminInsufficientRole(c, path, minimumRole) {
1475
+ const diagnostic = adminRoleDiagnostic(`${c.req.method} ${path}`, minimumRole, `This action requires the ${minimumRole} role.`);
1476
+ return Response.json({
1477
+ ok: false,
1478
+ diagnostic,
1479
+ }, { status: 403 });
1480
+ }
1481
+ function adminRoleDiagnostic(path, minimumRole, message) {
1482
+ return runtimeDiagnostic({
1483
+ code: "AUTH_DENIED",
1484
+ severity: "error",
1485
+ path,
1486
+ expected: `${minimumRole} role or higher for the signed-in user`,
1487
+ message,
1488
+ });
1489
+ }
1490
+ function adminNotStaff(c, path, login) {
1491
+ return Response.json({
1492
+ ok: false,
1493
+ login,
1494
+ diagnostic: runtimeDiagnostic({
1495
+ code: "AUTH_DENIED",
1496
+ severity: "error",
1497
+ path: `${c.req.method} ${path}`,
1498
+ expected: "staff role for the signed-in user",
1499
+ message: "Signed in, but this account isn't on the admin staff list. Contact a site owner to be added.",
1500
+ }),
1501
+ }, { status: 403 });
1502
+ }
1503
+ function jsonError(args) {
1504
+ const diagnostic = {
1505
+ code: args.code,
1506
+ severity: "error",
1507
+ phase: "runtime",
1508
+ path: "mount/http",
1509
+ message: args.message,
1510
+ };
1511
+ return Response.json({ ok: false, diagnostic }, { status: args.status });
1512
+ }
1513
+ function mediaNotConfiguredResponse(path) {
1514
+ return Response.json({
1515
+ ok: false,
1516
+ diagnostic: runtimeDiagnostic({
1517
+ code: "MEDIA_NOT_CONFIGURED",
1518
+ severity: "error",
1519
+ path,
1520
+ message: "Media uploads are not enabled on this deployment. Bind a `mediaStorage` port in `createMantleRuntime` to enable.",
1521
+ }),
1522
+ }, { status: 501 });
1523
+ }
1524
+ //# sourceMappingURL=mountMantleAdmin.js.map