@aotter/mantle-admin 0.1.0-alpha.9 → 0.1.2-alpha.2

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.
@@ -1,5 +1,8 @@
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";
1
+ import { bodyLimit } from "hono/body-limit";
2
+ import { staffMcp } from "./staffMcp.js";
3
+ import { rejectCrossOriginMutation } from "./rejectCrossOriginMutation.js";
4
+ import { DiagnosticError, HTTP_STATUS_BY_CODE, MANTLE_REF_KEYWORD, MCP_HINT_KEYWORD, VIEW_PARAMS_RESERVED, isMediaMcpHint, httpStatusFor, meetsRole, redactForWire, resolveLocalizedText, resolveLifecycle, runtimeDiagnostic, checkSchemaAdminUi, checkViewAdminUi, schemaSortableFields, STAFF_ROLES, } from "@aotter/mantle-spec";
5
+ import { ViewParamCoercionError, MAX_JSON_BODY_BYTES, coerceViewParams, evaluateAuthAll, projectCallableCapabilities, } from "@aotter/mantle-runtime";
3
6
  const STAFF_ROLE_SET = new Set(STAFF_ROLES);
4
7
  const [PAGE_PARAM, SHOW_PARAM] = VIEW_PARAMS_RESERVED;
5
8
  const MEMBER_CURSOR_PREFIX = "m:";
@@ -27,8 +30,19 @@ export function mountMantleAdmin(app, ref) {
27
30
  const authBasePath = auth.basePath;
28
31
  const spa = async (c) => {
29
32
  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" } });
33
+ if (asset) {
34
+ const headers = new Headers(asset.headers);
35
+ // Append a separate policy so an asset's existing CSP remains effective.
36
+ headers.append("content-security-policy", "frame-ancestors 'none'");
37
+ headers.set("x-frame-options", "DENY");
38
+ headers.set("cache-control", "private, no-store");
39
+ return new Response(asset.body, { status: asset.status, headers });
40
+ }
41
+ return new Response("Mantle Admin assets are missing; run `mantle generate` and configure the ASSETS binding.", { status: 503, headers: { "cache-control": "private, no-store" } });
31
42
  };
43
+ const limitBody = bodyLimit({ maxSize: MAX_JSON_BODY_BYTES });
44
+ app.use("/admin/api/*", limitBody);
45
+ app.use(`${authBasePath}/*`, limitBody);
32
46
  // Public read-only manifest of registered sign-in methods. The admin
33
47
  // SPA hits this on sign-in-page mount so it can render per-method
34
48
  // sections without baking the method list into its build. No secrets
@@ -64,10 +78,13 @@ export function mountMantleAdmin(app, ref) {
64
78
  "/admin/c/:collection/:id",
65
79
  "/admin/media",
66
80
  "/admin/preferences",
81
+ "/admin/connected-apps",
67
82
  "/admin/settings",
68
83
  "/admin/staff",
69
84
  "/admin/members",
70
85
  "/admin/ops",
86
+ "/admin/dev",
87
+ "/admin/dev/*",
71
88
  "/admin/views/:name",
72
89
  ]) {
73
90
  app.get(path, spa);
@@ -89,8 +106,8 @@ export function mountMantleAdmin(app, ref) {
89
106
  message: `Schema '${schema.metadata.name}' is read-only on generic authoring surfaces; use its declared Procedures.`,
90
107
  }));
91
108
  };
92
- const readMutableEntry = async (runtime, id, path) => {
93
- const entry = await runtime.getEntry.execute({ id });
109
+ const readMutableEntry = async (runtime, collection, id, path) => {
110
+ const entry = await runtime.getEntry.execute({ id, collection });
94
111
  assertMutableSchema(path, schemasByName.get(entry.collection));
95
112
  return entry;
96
113
  };
@@ -114,29 +131,53 @@ export function mountMantleAdmin(app, ref) {
114
131
  // `/admin/api/collections` precedent exactly (same shape of
115
132
  // "precompute at mount from ref.plan, list on GET") and needs
116
133
  // 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) => ({
134
+ const views = Object.values(ref.plan.views).map(({ manifest }) => manifest);
135
+ const viewsManifest = views.map((v) => ({
125
136
  name: v.metadata.name,
126
137
  title: v.spec.title ?? null,
138
+ surface: v.spec.surface,
127
139
  from: v.spec.from ?? null,
128
140
  params: v.spec.params ?? null,
129
141
  fields: v.spec.fields ?? null,
130
142
  list: checkViewAdminUi(v).list,
131
143
  }));
144
+ const developerConsole = projectDeveloperConsole(ref.plan);
132
145
  const guarded = (method, path, body) => {
133
146
  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);
147
+ const trace = path === "/admin/api/bootstrap" || path === "/admin/api/entries"
148
+ ? {
149
+ kind: path === "/admin/api/bootstrap" ? "mantle-admin-bootstrap-v1" : "mantle-admin-entries-v1",
150
+ requestId: crypto.randomUUID(), ray: c.req.header("cf-ray") ?? null, startedAt: performance.now(),
151
+ }
152
+ : undefined;
153
+ let status = null;
154
+ try {
155
+ const rejected = rejectCrossOriginMutation(c.req.raw);
156
+ if (rejected)
157
+ return rejected;
158
+ const sessionStartedAt = performance.now();
159
+ const gate = await readStaffGate(c, auth);
160
+ if (trace)
161
+ trace.sessionMs = elapsed(sessionStartedAt);
162
+ if (gate.kind === "unauth")
163
+ return adminUnauthenticated(c, path);
164
+ if (gate.kind === "forbidden")
165
+ return adminNotStaff(c, path, gate.login);
166
+ const response = await body(c, gate, trace);
167
+ status = response.status;
168
+ return response;
169
+ }
170
+ finally {
171
+ if (trace)
172
+ console.info(JSON.stringify({
173
+ kind: trace.kind, requestId: trace.requestId, ray: trace.ray, status,
174
+ totalMs: elapsed(trace.startedAt), sessionMs: trace.sessionMs ?? null,
175
+ runtimeMs: trace.runtimeMs ?? null, siteMs: trace.siteMs ?? null,
176
+ catalogMs: trace.catalogMs ?? null, entriesMs: trace.entriesMs ?? null,
177
+ listMs: trace.listMs ?? null, translationsMs: trace.translationsMs ?? null,
178
+ serializeMs: trace.serializeMs ?? null, rows: trace.rows ?? null,
179
+ }));
180
+ }
140
181
  });
141
182
  };
142
183
  const roleGuarded = (method, path, minimumRole, body) => {
@@ -147,6 +188,14 @@ export function mountMantleAdmin(app, ref) {
147
188
  return body(c, gate);
148
189
  });
149
190
  };
191
+ guarded("get", "/admin/api/webmcp", async () => {
192
+ const { tools, routes } = await staffMcp(await ref.get(), ref.plan);
193
+ return Response.json({ tools, routes }, { headers: { "cache-control": "private, no-store" } });
194
+ });
195
+ guarded("post", "/admin/api/mcp", async (c, gate) => {
196
+ const { dispatcher } = await staffMcp(await ref.get(), ref.plan);
197
+ return dispatcher.dispatch(c.req.raw, adminHandlerContext(c, gate, ref));
198
+ });
150
199
  guarded("get", "/admin/api/me", (_c, gate) => Response.json({ login: gate.login, role: gate.role, userId: gate.userId, image: gate.image }));
151
200
  roleGuarded("get", "/admin/api/staff", "owner", async () => Response.json({ users: await auth.listUsers() }));
152
201
  roleGuarded("get", "/admin/api/members", "editor", async (c) => {
@@ -257,7 +306,8 @@ export function mountMantleAdmin(app, ref) {
257
306
  }
258
307
  await auth.setUserRole(result.id, role);
259
308
  }
260
- return Response.json({ ok: true, userId: result.id });
309
+ await auth.sendStaffInvitation?.(email, role);
310
+ return Response.json({ ok: true, userId: result.id, emailSent: Boolean(auth.sendStaffInvitation) });
261
311
  });
262
312
  roleGuarded("delete", "/admin/api/staff/invitations/:id", "owner", async (c) => {
263
313
  const revoked = await auth.revokeInvite(c.req.param("id") ?? "");
@@ -276,13 +326,33 @@ export function mountMantleAdmin(app, ref) {
276
326
  return Response.json({ ok: true });
277
327
  });
278
328
  guarded("get", "/admin/api/collections", () => Response.json({ collections }));
329
+ guarded("get", "/admin/api/collections/:name/statistics", async (c) => {
330
+ const collection = collections.find((item) => item.name === c.req.param("name") &&
331
+ (!item.parent || item.nav?.standalone === true));
332
+ if (!collection)
333
+ return Response.json({ error: "Collection not found" }, { status: 404 });
334
+ const ranges = {
335
+ "1h": [3_600_000, 300_000], "24h": [86_400_000, 3_600_000],
336
+ "7d": [7 * 86_400_000, 6 * 3_600_000], "20d": [20 * 86_400_000, 86_400_000],
337
+ };
338
+ const range = c.req.query("range") ?? "7d";
339
+ if (!Object.hasOwn(ranges, range))
340
+ return Response.json({ error: "Invalid statistics range" }, { status: 400 });
341
+ const [duration, bucketMs] = ranges[range];
342
+ const runtime = await ref.get();
343
+ if (!runtime.entries.readCreationStatistics) {
344
+ return Response.json({ error: "Statistics unavailable for this storage adapter" }, { status: 501 });
345
+ }
346
+ const to = Date.now();
347
+ const from = to - duration;
348
+ const statistics = await runtime.entries.readCreationStatistics({ collection: collection.name, from, to, bucketMs });
349
+ return Response.json({ ...statistics, from, to, bucketMs }, { headers: { "cache-control": "private, no-store" } });
350
+ });
351
+ roleGuarded("get", "/admin/api/developer-console", "owner", () => Response.json(developerConsole));
279
352
  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) {
353
+ // Admin inspection is staff-gated for both surfaces. Public Views keep
354
+ // their separate public `/api/views/<name>` contract.
355
+ for (const v of views) {
286
356
  const viewName = v.metadata.name;
287
357
  guarded("get", `/admin/api/views/${viewName}`, async (c, gate) => {
288
358
  const runtime = await ref.get();
@@ -293,7 +363,7 @@ export function mountMantleAdmin(app, ref) {
293
363
  return handleViewRequest(c.req.raw, runtime, v, adminHandlerContext(c, gate, ref), true);
294
364
  });
295
365
  }
296
- guarded("get", "/admin/api/operations", (c, gate) => Response.json({
366
+ const operationsPayload = (c, gate) => ({
297
367
  operations: operations.filter((op) => evaluateAuthAll(op.procedure.spec.requires, adminHandlerContext(c, gate, ref), `GET /admin/api/operations/${op.name}`, "runtime") === null).map((op) => ({
298
368
  name: op.name,
299
369
  title: op.title,
@@ -302,8 +372,12 @@ export function mountMantleAdmin(app, ref) {
302
372
  uiSchema: op.uiSchema,
303
373
  triggers: op.triggers,
304
374
  rowBindings: op.rowBindings,
375
+ targetCollection: op.procedure.spec.handler.kind === "builtin"
376
+ ? op.procedure.spec.handler.schema
377
+ : null,
305
378
  })),
306
- }));
379
+ });
380
+ guarded("get", "/admin/api/operations", (c, gate) => Response.json(operationsPayload(c, gate)));
307
381
  guarded("post", "/admin/api/operations/:name", async (c, gate) => {
308
382
  const name = c.req.param("name") ?? "";
309
383
  const op = operationsByName.get(name);
@@ -340,16 +414,17 @@ export function mountMantleAdmin(app, ref) {
340
414
  return { ok: true, output: result.data };
341
415
  });
342
416
  });
343
- guarded("get", "/admin/api/site", async (c) => {
344
- const runtime = await ref.get();
345
- const { origin, ...site } = await runtime.siteConfig.load();
417
+ const sitePayload = async (c, runtime) => {
418
+ const resolvedRuntime = runtime ?? await ref.get();
419
+ const { origin, ...site } = await resolvedRuntime.siteConfig.load();
346
420
  const publicUrl = origin || new URL(c.req.url).origin;
347
- return Response.json({
421
+ return {
348
422
  ...site,
349
423
  publicUrl,
350
424
  mcpUrl: `${publicUrl}/mcp/staff`,
351
- });
352
- });
425
+ };
426
+ };
427
+ guarded("get", "/admin/api/site", async (c) => Response.json(await sitePayload(c)));
353
428
  roleGuarded("get", "/admin/api/site-settings", "owner", async () => runMantleUseCase("GET /admin/api/site-settings", async () => {
354
429
  const runtime = await ref.get();
355
430
  return adminSiteSettings(await runtime.siteConfig.load());
@@ -366,7 +441,7 @@ export function mountMantleAdmin(app, ref) {
366
441
  });
367
442
  return adminSiteSettings(site);
368
443
  }));
369
- guarded("get", "/admin/api/entries", async (c) => {
444
+ const entriesPayload = async (c, runtime, trace) => {
370
445
  const collection = c.req.query("collection");
371
446
  if (!collection) {
372
447
  return Response.json({
@@ -380,7 +455,10 @@ export function mountMantleAdmin(app, ref) {
380
455
  }),
381
456
  }, { status: 400 });
382
457
  }
383
- const runtime = await ref.get();
458
+ const runtimeStartedAt = performance.now();
459
+ const resolvedRuntime = runtime ?? await ref.get();
460
+ if (trace)
461
+ trace.runtimeMs = elapsed(runtimeStartedAt);
384
462
  const rawLimit = c.req.query("limit");
385
463
  const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : NaN;
386
464
  const statusQuery = c.req.query("status");
@@ -399,10 +477,25 @@ export function mountMantleAdmin(app, ref) {
399
477
  }),
400
478
  }, { status: 400 });
401
479
  }
480
+ const scopeField = c.req.query("scope_field");
481
+ const scopeValue = c.req.query("scope_value");
482
+ if (Boolean(scopeField) !== Boolean(scopeValue)) {
483
+ return Response.json({
484
+ ok: false,
485
+ diagnostic: runtimeDiagnostic({
486
+ code: "INPUT_VALIDATION_FAILED",
487
+ severity: "error",
488
+ path: "GET /admin/api/entries",
489
+ expected: "scope_field and scope_value together",
490
+ message: "List parent scope requires both `scope_field` and `scope_value`.",
491
+ }),
492
+ }, { status: 400 });
493
+ }
402
494
  // Admin pagination needs the cursored shape — `executePage` returns
403
495
  // `{ rows, nextCursor? }`. `execute()` is the flat-array variant
404
496
  // for app code.
405
- const result = await runtime.listEntries.executePage({
497
+ const listStartedAt = performance.now();
498
+ const result = await resolvedRuntime.listEntries.executePage({
406
499
  collection,
407
500
  status: statusQuery && statusQuery !== "all" ? statusQuery : undefined,
408
501
  limit: Number.isFinite(parsedLimit) ? parsedLimit : 99,
@@ -412,11 +505,19 @@ export function mountMantleAdmin(app, ref) {
412
505
  filter: filterField && filterValue
413
506
  ? { field: filterField, value: filterValue }
414
507
  : undefined,
508
+ scope: scopeField && scopeValue
509
+ ? { field: scopeField, value: scopeValue }
510
+ : undefined,
415
511
  sort: {
416
512
  field: c.req.query("sort") || "updatedAt",
417
513
  direction: sortDirection,
418
514
  },
419
515
  });
516
+ if (trace) {
517
+ trace.listMs = elapsed(listStartedAt);
518
+ trace.rows = result.rows.length;
519
+ }
520
+ const translationsStartedAt = performance.now();
420
521
  const translationLocales = new Map();
421
522
  const translationSchemas = schemas.filter((schema) => schema.spec.translates?.parent === collection);
422
523
  for (const schema of translationSchemas) {
@@ -427,7 +528,7 @@ export function mountMantleAdmin(app, ref) {
427
528
  if (value !== null)
428
529
  parentValues.set(joinValueKey(value), value);
429
530
  }
430
- const translations = await runtime.entries.readByDataFieldIn({
531
+ const translations = await resolvedRuntime.entries.readByDataFieldIn({
431
532
  collection: schema.metadata.name,
432
533
  field,
433
534
  values: [...parentValues.values()],
@@ -452,12 +553,61 @@ export function mountMantleAdmin(app, ref) {
452
553
  translationLocales.set(row.id, locales);
453
554
  }
454
555
  }
556
+ if (trace)
557
+ trace.translationsMs = elapsed(translationsStartedAt);
455
558
  const items = result.rows.map((row) => adminListItem(row, schemasByName, [...(translationLocales.get(row.id) ?? [])]));
456
- return Response.json({
559
+ return {
457
560
  items,
458
561
  previous_cursor: result.previousCursor ?? null,
459
562
  next_cursor: result.nextCursor ?? null,
460
- });
563
+ };
564
+ };
565
+ guarded("get", "/admin/api/entries", async (c, _gate, trace) => {
566
+ const payload = await entriesPayload(c, undefined, trace);
567
+ if (payload instanceof Response)
568
+ return payload;
569
+ const serializeStartedAt = performance.now();
570
+ const response = Response.json(payload);
571
+ if (trace)
572
+ trace.serializeMs = elapsed(serializeStartedAt);
573
+ return response;
574
+ });
575
+ guarded("get", "/admin/api/bootstrap", async (c, gate, trace) => {
576
+ const runtimeStartedAt = performance.now();
577
+ const runtime = await ref.get();
578
+ if (trace)
579
+ trace.runtimeMs = elapsed(runtimeStartedAt);
580
+ const siteStartedAt = performance.now();
581
+ const catalogStartedAt = performance.now();
582
+ const entriesStartedAt = performance.now();
583
+ const [site, catalog, entries] = await Promise.all([
584
+ sitePayload(c, runtime).finally(() => { if (trace)
585
+ trace.siteMs = elapsed(siteStartedAt); }),
586
+ staffMcp(runtime, ref.plan).finally(() => { if (trace)
587
+ trace.catalogMs = elapsed(catalogStartedAt); }),
588
+ entriesPayload(c, runtime, trace).finally(() => { if (trace)
589
+ trace.entriesMs = elapsed(entriesStartedAt); }),
590
+ ]);
591
+ if (entries instanceof Response)
592
+ return entries;
593
+ const serializeStartedAt = performance.now();
594
+ const response = Response.json({
595
+ me: {
596
+ login: gate.login,
597
+ role: gate.role,
598
+ userId: gate.userId,
599
+ image: gate.image,
600
+ },
601
+ collections,
602
+ site,
603
+ operations: operationsPayload(c, gate).operations,
604
+ views: viewsManifest,
605
+ webmcp: { tools: catalog.tools, routes: catalog.routes },
606
+ entries,
607
+ }, { headers: { "cache-control": "private, no-store" } });
608
+ if (trace)
609
+ trace.serializeMs = elapsed(serializeStartedAt);
610
+ return response;
461
611
  });
462
612
  guarded("get", "/admin/api/entries/export", async (c) => {
463
613
  const collection = c.req.query("collection");
@@ -492,11 +642,26 @@ export function mountMantleAdmin(app, ref) {
492
642
  }),
493
643
  }, { status: 400 });
494
644
  }
645
+ const scopeField = c.req.query("scope_field");
646
+ const scopeValue = c.req.query("scope_value");
647
+ if (Boolean(scopeField) !== Boolean(scopeValue)) {
648
+ return Response.json({
649
+ ok: false,
650
+ diagnostic: runtimeDiagnostic({
651
+ code: "INPUT_VALIDATION_FAILED",
652
+ severity: "error",
653
+ path: "GET /admin/api/entries/export",
654
+ expected: "scope_field and scope_value together",
655
+ message: "List parent scope requires both `scope_field` and `scope_value`.",
656
+ }),
657
+ }, { status: 400 });
658
+ }
495
659
  const listOptions = {
496
660
  collection,
497
661
  status: statusQuery && statusQuery !== "all" ? statusQuery : undefined,
498
662
  search: c.req.query("search") || undefined,
499
663
  filter: filterField && filterValue ? { field: filterField, value: filterValue } : undefined,
664
+ scope: scopeField && scopeValue ? { field: scopeField, value: scopeValue } : undefined,
500
665
  sort: {
501
666
  field: c.req.query("sort") || "updatedAt",
502
667
  direction: c.req.query("direction") === "asc" ? "asc" : "desc",
@@ -524,7 +689,8 @@ export function mountMantleAdmin(app, ref) {
524
689
  guarded("get", "/admin/api/entries/:id", async (c) => runMantleUseCase(`GET /admin/api/entries/${c.req.param("id")}`, async () => {
525
690
  const runtime = await ref.get();
526
691
  const id = c.req.param("id");
527
- const row = await runtime.getEntry.execute({ id });
692
+ const collection = requiredCollection(c.req.query("collection"), schemasByName, `GET /admin/api/entries/${id}`);
693
+ const row = await runtime.getEntry.execute({ id, collection });
528
694
  return entryEditorPayload(runtime, row, schemas);
529
695
  }));
530
696
  guarded("post", "/admin/api/entries", async (c, gate) => runMantleUseCase("POST /admin/api/entries", async () => {
@@ -556,7 +722,8 @@ export function mountMantleAdmin(app, ref) {
556
722
  guarded("patch", "/admin/api/entries/:id", async (c, gate) => runMantleUseCase(`PATCH /admin/api/entries/${c.req.param("id")}`, async () => {
557
723
  const runtime = await ref.get();
558
724
  const id = c.req.param("id");
559
- const current = await readMutableEntry(runtime, id, `PATCH /admin/api/entries/${id}`);
725
+ const collection = requiredCollection(c.req.query("collection"), schemasByName, `PATCH /admin/api/entries/${id}`);
726
+ const current = await readMutableEntry(runtime, collection, id, `PATCH /admin/api/entries/${id}`);
560
727
  if (gate.role === "contributor") {
561
728
  const lifecycle = schemasByName.get(current.collection)?.spec.lifecycle ?? "publishing";
562
729
  if (lifecycle === "operational" || current.status !== "draft") {
@@ -566,6 +733,7 @@ export function mountMantleAdmin(app, ref) {
566
733
  const body = (await c.req.raw.json().catch(() => ({})));
567
734
  const updated = await runtime.updateDraft.execute({
568
735
  id,
736
+ collection,
569
737
  expectedVersion: expectedVersionField(body.expectedVersion, `PATCH /admin/api/entries/${id}#/expectedVersion`),
570
738
  data: objectField(body.data),
571
739
  ctx: adminHandlerContext(c, gate, ref),
@@ -576,10 +744,12 @@ export function mountMantleAdmin(app, ref) {
576
744
  roleGuarded("post", "/admin/api/entries/:id/publish", "editor", async (c, gate) => runMantleUseCase(`POST /admin/api/entries/${c.req.param("id")}/publish`, async () => {
577
745
  const runtime = await ref.get();
578
746
  const id = c.req.param("id");
579
- await readMutableEntry(runtime, id, `POST /admin/api/entries/${id}/publish`);
747
+ const collection = requiredCollection(c.req.query("collection"), schemasByName, `POST /admin/api/entries/${id}/publish`);
748
+ await readMutableEntry(runtime, collection, id, `POST /admin/api/entries/${id}/publish`);
580
749
  const body = await c.req.raw.json().catch(() => ({}));
581
750
  const row = await runtime.requestPublish.execute({
582
751
  id,
752
+ collection,
583
753
  ctx: adminHandlerContext(c, gate, ref),
584
754
  originalInput: body,
585
755
  });
@@ -588,10 +758,12 @@ export function mountMantleAdmin(app, ref) {
588
758
  roleGuarded("post", "/admin/api/entries/:id/unpublish", "editor", async (c, gate) => runMantleUseCase(`POST /admin/api/entries/${c.req.param("id")}/unpublish`, async () => {
589
759
  const runtime = await ref.get();
590
760
  const id = c.req.param("id");
591
- await readMutableEntry(runtime, id, `POST /admin/api/entries/${id}/unpublish`);
761
+ const collection = requiredCollection(c.req.query("collection"), schemasByName, `POST /admin/api/entries/${id}/unpublish`);
762
+ await readMutableEntry(runtime, collection, id, `POST /admin/api/entries/${id}/unpublish`);
592
763
  const body = await c.req.raw.json().catch(() => ({}));
593
764
  const row = await runtime.unpublish.execute({
594
765
  id,
766
+ collection,
595
767
  ctx: adminHandlerContext(c, gate, ref),
596
768
  originalInput: body,
597
769
  });
@@ -600,10 +772,12 @@ export function mountMantleAdmin(app, ref) {
600
772
  roleGuarded("delete", "/admin/api/entries/:id", "editor", async (c, gate) => runMantleUseCase(`DELETE /admin/api/entries/${c.req.param("id")}`, async () => {
601
773
  const runtime = await ref.get();
602
774
  const id = c.req.param("id");
603
- await readMutableEntry(runtime, id, `DELETE /admin/api/entries/${id}`);
775
+ const collection = requiredCollection(c.req.query("collection"), schemasByName, `DELETE /admin/api/entries/${id}`);
776
+ await readMutableEntry(runtime, collection, id, `DELETE /admin/api/entries/${id}`);
604
777
  const body = await c.req.raw.json().catch(() => ({}));
605
778
  return runtime.deleteEntry.execute({
606
779
  id,
780
+ collection,
607
781
  ctx: adminHandlerContext(c, gate, ref),
608
782
  originalInput: body,
609
783
  });
@@ -912,7 +1086,14 @@ function hasCtxStaffPredicate(procedure) {
912
1086
  const predicates = procedure.spec.requires?.auth?.all ?? [];
913
1087
  return predicates.some((pred) => typeof pred === "object" && pred !== null && "ctx.staff" in pred);
914
1088
  }
915
- function adminEntryTitle(data, schema) {
1089
+ function adminEntryTitle(data, schema, primaryField) {
1090
+ if (primaryField) {
1091
+ const value = data[primaryField];
1092
+ if (typeof value === "string" && value.trim())
1093
+ return value;
1094
+ if (typeof value === "number" || typeof value === "boolean")
1095
+ return value;
1096
+ }
916
1097
  const key = titleFieldKey(data, schema);
917
1098
  return key ? data[key] : null;
918
1099
  }
@@ -1057,10 +1238,12 @@ async function entryEditorPayload(runtime, row, schemas) {
1057
1238
  }));
1058
1239
  }
1059
1240
  const related = await relatedEntrySections(runtime, schema, row, schemas);
1241
+ const parent = await parentEntry(runtime, schema, row, schemas);
1060
1242
  return {
1061
1243
  collection: adminEditorCollection(schema, schemas),
1062
1244
  entry: adminEditorEntry(row),
1063
- parentEntryId: await parentEntryId(runtime, schema, row, schemas),
1245
+ parentEntryId: parent?.id ?? null,
1246
+ parentEntryTitle: parent ? parentEntryTitle(parent, schemas) : null,
1064
1247
  related,
1065
1248
  };
1066
1249
  }
@@ -1081,6 +1264,7 @@ function adminEditorCollection(schema, schemas) {
1081
1264
  sortableFields: schemaSortableFields(schema),
1082
1265
  filter: adminUi.filter,
1083
1266
  list: adminUi.list,
1267
+ nav: adminUi.nav,
1084
1268
  };
1085
1269
  }
1086
1270
  function adminEditorEntry(row) {
@@ -1184,16 +1368,25 @@ function collectionParentFor(childSchema, schemas) {
1184
1368
  }
1185
1369
  return null;
1186
1370
  }
1187
- async function parentEntryId(runtime, childSchema, childRow, schemas) {
1371
+ async function parentEntry(runtime, childSchema, childRow, schemas) {
1188
1372
  const parent = collectionParentFor(childSchema, schemas);
1189
1373
  if (!parent)
1190
1374
  return null;
1191
1375
  const value = primitiveJoinValue(childRow.data[parent.childField]);
1192
1376
  if (value === null)
1193
1377
  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;
1378
+ if (parent.parentField === "id" && typeof value === "string") {
1379
+ return runtime.getEntry.execute({ id: value, collection: parent.collection });
1380
+ }
1381
+ return (await entriesByDataValue(runtime, parent.collection, parent.parentField, value))[0] ?? null;
1382
+ }
1383
+ function parentEntryTitle(entry, schemas) {
1384
+ const schema = schemas.find((candidate) => candidate.metadata.name === entry.collection);
1385
+ const primaryField = schema ? checkSchemaAdminUi(schema).list.primaryField : null;
1386
+ const value = adminEntryTitle(entry.data, schema?.spec.schema, primaryField);
1387
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
1388
+ ? String(value)
1389
+ : entry.id;
1197
1390
  }
1198
1391
  function primitiveJoinValue(value) {
1199
1392
  if (typeof value === "string" && value !== "")
@@ -1238,11 +1431,27 @@ function expectedVersionField(value, path) {
1238
1431
  function stringField(value) {
1239
1432
  return typeof value === "string" ? value : undefined;
1240
1433
  }
1434
+ function requiredCollection(value, schemas, path) {
1435
+ if (value && schemas.has(value))
1436
+ return value;
1437
+ throw new DiagnosticError(runtimeDiagnostic({
1438
+ code: "INPUT_VALIDATION_FAILED",
1439
+ severity: "error",
1440
+ path: `${path}#/collection`,
1441
+ value,
1442
+ expected: "the name of a declared Schema",
1443
+ candidates: [...schemas.keys()],
1444
+ message: "A valid `collection` query parameter is required.",
1445
+ }));
1446
+ }
1241
1447
  function objectField(value) {
1242
1448
  return typeof value === "object" && value !== null && !Array.isArray(value)
1243
1449
  ? value
1244
1450
  : {};
1245
1451
  }
1452
+ function elapsed(startedAt) {
1453
+ return Math.round((performance.now() - startedAt) * 100) / 100;
1454
+ }
1246
1455
  function adminHandlerContext(c, gate, ref) {
1247
1456
  const request = ref.requestContext?.(c);
1248
1457
  return {
@@ -1262,8 +1471,11 @@ async function readStaffGate(c, auth) {
1262
1471
  const session = await auth.getSession(c.req.raw);
1263
1472
  if (!session)
1264
1473
  return { kind: "unauth" };
1265
- const role = await auth.getUserRole(session.user.id);
1266
- const login = session.user.githubLogin ?? null;
1474
+ const role = session.user.roleCurrent
1475
+ ? session.user.role ?? null
1476
+ : await auth.getUserRole(session.user.id);
1477
+ const login = [session.user.githubLogin, session.user.name, session.user.email]
1478
+ .find((value) => value?.trim()) ?? null;
1267
1479
  if (!role || !STAFF_ROLE_SET.has(role)) {
1268
1480
  return { kind: "forbidden", login };
1269
1481
  }
@@ -1417,6 +1629,178 @@ function mediaFieldsForSchema(schema) {
1417
1629
  }
1418
1630
  return out;
1419
1631
  }
1632
+ function developerRelation(id, kind, sourceId, targetId, pointer, value) {
1633
+ return { id, kind, sourceId, targetId, pointer, value };
1634
+ }
1635
+ function jsonPointerSegment(value) {
1636
+ return value.replace(/~/g, "~0").replace(/\//g, "~1");
1637
+ }
1638
+ function developerAudience(predicates) {
1639
+ if (predicates.some((predicate) => typeof predicate === "object" && "ctx.staff" in predicate))
1640
+ return "staff";
1641
+ if (predicates.includes("ctx.user"))
1642
+ return "members";
1643
+ if (predicates.some((predicate) => predicate === "ctx.auth" || (typeof predicate === "object" && "ctx.auth.scope" in predicate)))
1644
+ return "api-clients";
1645
+ return null;
1646
+ }
1647
+ function projectDeveloperConsole(plan) {
1648
+ const schemasByName = new Map(Object.values(plan.schemas).map(({ name, manifest }) => [name, manifest]));
1649
+ const procedures = Object.values(plan.procedures).map(({ name, manifest, authorization, guard }) => ({
1650
+ name,
1651
+ title: manifest.spec.title ?? null,
1652
+ description: manifest.spec.description ?? null,
1653
+ audience: developerAudience(authorization?.all ?? []) ?? "public",
1654
+ input: manifest.spec.input,
1655
+ output: manifest.spec.output,
1656
+ authorization: authorization?.all ?? [],
1657
+ guard: guard ?? null,
1658
+ handler: manifest.spec.handler,
1659
+ manifest,
1660
+ })).sort((a, b) => a.name.localeCompare(b.name));
1661
+ const triggers = Object.values(plan.triggers).map(({ name, manifest, target }) => {
1662
+ const source = manifest.spec.source;
1663
+ const targetAudience = developerAudience(plan.procedures[target]?.authorization?.all ?? []);
1664
+ return {
1665
+ name,
1666
+ target,
1667
+ audience: source.kind === "lifecycle" ? "system" : source.kind === "mcp" && source.surface === "staff" ? "staff" : targetAudience ?? "public",
1668
+ source,
1669
+ manifest,
1670
+ };
1671
+ }).sort((a, b) => a.name.localeCompare(b.name));
1672
+ const graph = {
1673
+ atoms: [
1674
+ ...Object.values(plan.schemas).map(({ name, manifest }) => ({ id: `Schema:${name}`, kind: "Schema", name, title: manifest.spec.title })),
1675
+ ...Object.values(plan.views).map(({ name, manifest, authorization }) => ({
1676
+ id: `View:${name}`,
1677
+ kind: "View",
1678
+ name,
1679
+ title: manifest.spec.title ?? null,
1680
+ audience: manifest.spec.surface === "staff" ? "staff" : developerAudience(authorization?.all ?? []) ?? "public",
1681
+ })),
1682
+ ...procedures.map(({ name, title, description, audience, handler }) => ({
1683
+ id: `Procedure:${name}`,
1684
+ kind: "Procedure",
1685
+ name,
1686
+ title,
1687
+ description,
1688
+ audience,
1689
+ handler,
1690
+ })),
1691
+ ...triggers.map(({ name, source, audience }) => ({
1692
+ id: `Trigger:${name}`,
1693
+ kind: "Trigger",
1694
+ name,
1695
+ title: null,
1696
+ audience,
1697
+ transport: source.kind,
1698
+ })),
1699
+ ].sort((a, b) => a.id.localeCompare(b.id)),
1700
+ relations: [
1701
+ ...Object.values(plan.schemas).flatMap(({ name, manifest, translationParent }) => [
1702
+ ...(translationParent ? [developerRelation(`Schema:${name}:translates:${translationParent}`, "translation-parent", `Schema:${name}`, `Schema:${translationParent}`, "/spec/translates/parent", translationParent)] : []),
1703
+ ...Object.entries(manifest.spec.schema.properties ?? {}).flatMap(([field, property]) => {
1704
+ const target = property[MANTLE_REF_KEYWORD];
1705
+ if (typeof target !== "string" || !schemasByName.has(target))
1706
+ return [];
1707
+ return [developerRelation(`Schema:${name}:field:${field}:${target}`, "schema-reference", `Schema:${name}`, `Schema:${target}`, `/spec/schema/properties/${jsonPointerSegment(field)}/x-mantle-ref`, target)];
1708
+ }),
1709
+ ]),
1710
+ ...Object.values(plan.views).flatMap(({ name, query, guard }) => [
1711
+ ...(query.kind === "declarative" ? [developerRelation(`View:${name}:from:${query.from}`, "view-source", `View:${name}`, `Schema:${query.from}`, "/spec/from", query.from)] : []),
1712
+ ...(guard ? [developerRelation(`View:${name}:guard:${guard}`, "authorization-guard", `View:${name}`, `Procedure:${guard}`, "/spec/requires/guard/procedure", guard)] : []),
1713
+ ]),
1714
+ ...Object.values(plan.procedures).flatMap(({ name, manifest, builtinSchema, collectionActionSchema, guard }) => [
1715
+ ...(builtinSchema ? [developerRelation(`Procedure:${name}:builtin:${builtinSchema}`, "procedure-schema", `Procedure:${name}`, `Schema:${builtinSchema}`, "/spec/handler/schema", builtinSchema)] : []),
1716
+ ...(collectionActionSchema ? [developerRelation(`Procedure:${name}:collectionAction:${collectionActionSchema}`, "collection-action", `Procedure:${name}`, `Schema:${collectionActionSchema}`, "/spec/uiSchema/collectionAction", collectionActionSchema)] : []),
1717
+ ...discoverRowBindings(manifest, schemasByName).map(({ collection, inputField }) => developerRelation(`Procedure:${name}:input:${inputField}:${collection}`, "input-reference", `Procedure:${name}`, `Schema:${collection}`, `/spec/input/properties/${jsonPointerSegment(inputField)}/x-mantle-ref`, collection)),
1718
+ ...(guard ? [developerRelation(`Procedure:${name}:guard:${guard}`, "authorization-guard", `Procedure:${name}`, `Procedure:${guard}`, "/spec/requires/guard/procedure", guard)] : []),
1719
+ ]),
1720
+ ...Object.values(plan.triggers).flatMap(({ name, target, lifecycleSchema }) => [
1721
+ developerRelation(`Trigger:${name}:target:${target}`, "trigger-target", `Trigger:${name}`, `Procedure:${target}`, "/spec/target/procedure", target),
1722
+ ...(lifecycleSchema ? [developerRelation(`Trigger:${name}:source:${lifecycleSchema}`, "lifecycle-source", `Trigger:${name}`, `Schema:${lifecycleSchema}`, "/spec/source/schema", lifecycleSchema)] : []),
1723
+ ]),
1724
+ ].sort((a, b) => a.id.localeCompare(b.id)),
1725
+ };
1726
+ return {
1727
+ dataModel: {
1728
+ schemas: Object.values(plan.schemas).map(({ name, manifest }) => ({
1729
+ name,
1730
+ title: manifest.spec.title,
1731
+ lifecycle: resolveLifecycle(manifest),
1732
+ localized: manifest.spec.localized ?? false,
1733
+ translates: manifest.spec.translates ?? null,
1734
+ schema: manifest.spec.schema,
1735
+ uniqueIndexes: manifest.spec.uniqueIndexes ?? [],
1736
+ indexes: manifest.spec.indexes ?? [],
1737
+ searchableFields: manifest.spec.searchableFields ?? [],
1738
+ manifest,
1739
+ })).sort((a, b) => a.name.localeCompare(b.name)),
1740
+ views: Object.values(plan.views).map(({ name, manifest, query, authorization, guard }) => ({
1741
+ name,
1742
+ title: manifest.spec.title ?? null,
1743
+ surface: manifest.spec.surface,
1744
+ query,
1745
+ authorization: authorization?.all ?? [],
1746
+ guard: guard ?? null,
1747
+ manifest,
1748
+ })).sort((a, b) => a.name.localeCompare(b.name)),
1749
+ },
1750
+ logic: { triggers, procedures },
1751
+ interfaces: projectDeveloperInterfaces(plan),
1752
+ graph,
1753
+ };
1754
+ }
1755
+ function projectDeveloperInterfaces(plan) {
1756
+ const callable = projectCallableCapabilities(plan).map((capability) => ({
1757
+ kind: capability.kind,
1758
+ name: capability.name,
1759
+ target: capability.ownerName,
1760
+ surface: capability.surface,
1761
+ audience: developerAudience((capability.kind === "view"
1762
+ ? plan.views[capability.ownerName]?.authorization?.all
1763
+ : plan.procedures[capability.ownerName]?.authorization?.all) ?? []) ?? "public",
1764
+ title: capability.title ?? null,
1765
+ description: capability.description,
1766
+ input: capability.inputSchema,
1767
+ output: capability.kind === "procedure" ? capability.outputSchema : null,
1768
+ trigger: capability.kind === "procedure" ? capability.trigger : null,
1769
+ }));
1770
+ const http = [
1771
+ ...plan.httpRoutes.map((route) => {
1772
+ const procedure = plan.procedures[route.procedure];
1773
+ return {
1774
+ kind: "procedure",
1775
+ name: route.trigger,
1776
+ target: route.procedure,
1777
+ method: route.method,
1778
+ path: route.path,
1779
+ audience: developerAudience(procedure.authorization?.all ?? []) ?? "public",
1780
+ title: resolveLocalizedText(procedure.manifest.spec.title, "en") ?? null,
1781
+ description: resolveLocalizedText(procedure.manifest.spec.description, "en") ?? `Invoke Procedure '${route.procedure}'.`,
1782
+ input: procedure.manifest.spec.input,
1783
+ output: procedure.manifest.spec.output,
1784
+ };
1785
+ }),
1786
+ ...callable.filter((capability) => capability.kind === "view" && capability.surface === "public").map((capability) => {
1787
+ const view = plan.views[capability.target];
1788
+ return {
1789
+ kind: "view",
1790
+ name: capability.name,
1791
+ target: capability.target,
1792
+ method: "GET",
1793
+ path: `/api/views/${capability.target}`,
1794
+ audience: developerAudience(view.authorization?.all ?? []) ?? "public",
1795
+ title: capability.title,
1796
+ description: capability.description,
1797
+ input: capability.input,
1798
+ output: null,
1799
+ };
1800
+ }),
1801
+ ].sort((a, b) => `${a.path}\0${a.method}`.localeCompare(`${b.path}\0${b.method}`));
1802
+ return { http, callable };
1803
+ }
1420
1804
  export async function runMantleUseCase(operation, execute) {
1421
1805
  try {
1422
1806
  const result = await execute();