@aotter/mantle-admin 0.1.0-alpha.8 → 0.1.2-alpha.1

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);
@@ -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",
@@ -912,7 +1077,14 @@ function hasCtxStaffPredicate(procedure) {
912
1077
  const predicates = procedure.spec.requires?.auth?.all ?? [];
913
1078
  return predicates.some((pred) => typeof pred === "object" && pred !== null && "ctx.staff" in pred);
914
1079
  }
915
- function adminEntryTitle(data, schema) {
1080
+ function adminEntryTitle(data, schema, primaryField) {
1081
+ if (primaryField) {
1082
+ const value = data[primaryField];
1083
+ if (typeof value === "string" && value.trim())
1084
+ return value;
1085
+ if (typeof value === "number" || typeof value === "boolean")
1086
+ return value;
1087
+ }
916
1088
  const key = titleFieldKey(data, schema);
917
1089
  return key ? data[key] : null;
918
1090
  }
@@ -1057,10 +1229,12 @@ async function entryEditorPayload(runtime, row, schemas) {
1057
1229
  }));
1058
1230
  }
1059
1231
  const related = await relatedEntrySections(runtime, schema, row, schemas);
1232
+ const parent = await parentEntry(runtime, schema, row, schemas);
1060
1233
  return {
1061
1234
  collection: adminEditorCollection(schema, schemas),
1062
1235
  entry: adminEditorEntry(row),
1063
- parentEntryId: await parentEntryId(runtime, schema, row, schemas),
1236
+ parentEntryId: parent?.id ?? null,
1237
+ parentEntryTitle: parent ? parentEntryTitle(parent, schemas) : null,
1064
1238
  related,
1065
1239
  };
1066
1240
  }
@@ -1081,6 +1255,7 @@ function adminEditorCollection(schema, schemas) {
1081
1255
  sortableFields: schemaSortableFields(schema),
1082
1256
  filter: adminUi.filter,
1083
1257
  list: adminUi.list,
1258
+ nav: adminUi.nav,
1084
1259
  };
1085
1260
  }
1086
1261
  function adminEditorEntry(row) {
@@ -1184,16 +1359,26 @@ function collectionParentFor(childSchema, schemas) {
1184
1359
  }
1185
1360
  return null;
1186
1361
  }
1187
- async function parentEntryId(runtime, childSchema, childRow, schemas) {
1362
+ async function parentEntry(runtime, childSchema, childRow, schemas) {
1188
1363
  const parent = collectionParentFor(childSchema, schemas);
1189
1364
  if (!parent)
1190
1365
  return null;
1191
1366
  const value = primitiveJoinValue(childRow.data[parent.childField]);
1192
1367
  if (value === null)
1193
1368
  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;
1369
+ if (parent.parentField === "id" && typeof value === "string") {
1370
+ const entry = await runtime.getEntry.execute({ id: value });
1371
+ return entry.collection === parent.collection ? entry : null;
1372
+ }
1373
+ return (await entriesByDataValue(runtime, parent.collection, parent.parentField, value))[0] ?? null;
1374
+ }
1375
+ function parentEntryTitle(entry, schemas) {
1376
+ const schema = schemas.find((candidate) => candidate.metadata.name === entry.collection);
1377
+ const primaryField = schema ? checkSchemaAdminUi(schema).list.primaryField : null;
1378
+ const value = adminEntryTitle(entry.data, schema?.spec.schema, primaryField);
1379
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
1380
+ ? String(value)
1381
+ : entry.id;
1197
1382
  }
1198
1383
  function primitiveJoinValue(value) {
1199
1384
  if (typeof value === "string" && value !== "")
@@ -1243,6 +1428,9 @@ function objectField(value) {
1243
1428
  ? value
1244
1429
  : {};
1245
1430
  }
1431
+ function elapsed(startedAt) {
1432
+ return Math.round((performance.now() - startedAt) * 100) / 100;
1433
+ }
1246
1434
  function adminHandlerContext(c, gate, ref) {
1247
1435
  const request = ref.requestContext?.(c);
1248
1436
  return {
@@ -1262,8 +1450,11 @@ async function readStaffGate(c, auth) {
1262
1450
  const session = await auth.getSession(c.req.raw);
1263
1451
  if (!session)
1264
1452
  return { kind: "unauth" };
1265
- const role = await auth.getUserRole(session.user.id);
1266
- const login = session.user.githubLogin ?? null;
1453
+ const role = session.user.roleCurrent
1454
+ ? session.user.role ?? null
1455
+ : await auth.getUserRole(session.user.id);
1456
+ const login = [session.user.githubLogin, session.user.name, session.user.email]
1457
+ .find((value) => value?.trim()) ?? null;
1267
1458
  if (!role || !STAFF_ROLE_SET.has(role)) {
1268
1459
  return { kind: "forbidden", login };
1269
1460
  }
@@ -1417,6 +1608,178 @@ function mediaFieldsForSchema(schema) {
1417
1608
  }
1418
1609
  return out;
1419
1610
  }
1611
+ function developerRelation(id, kind, sourceId, targetId, pointer, value) {
1612
+ return { id, kind, sourceId, targetId, pointer, value };
1613
+ }
1614
+ function jsonPointerSegment(value) {
1615
+ return value.replace(/~/g, "~0").replace(/\//g, "~1");
1616
+ }
1617
+ function developerAudience(predicates) {
1618
+ if (predicates.some((predicate) => typeof predicate === "object" && "ctx.staff" in predicate))
1619
+ return "staff";
1620
+ if (predicates.includes("ctx.user"))
1621
+ return "members";
1622
+ if (predicates.some((predicate) => predicate === "ctx.auth" || (typeof predicate === "object" && "ctx.auth.scope" in predicate)))
1623
+ return "api-clients";
1624
+ return null;
1625
+ }
1626
+ function projectDeveloperConsole(plan) {
1627
+ const schemasByName = new Map(Object.values(plan.schemas).map(({ name, manifest }) => [name, manifest]));
1628
+ const procedures = Object.values(plan.procedures).map(({ name, manifest, authorization, guard }) => ({
1629
+ name,
1630
+ title: manifest.spec.title ?? null,
1631
+ description: manifest.spec.description ?? null,
1632
+ audience: developerAudience(authorization?.all ?? []) ?? "public",
1633
+ input: manifest.spec.input,
1634
+ output: manifest.spec.output,
1635
+ authorization: authorization?.all ?? [],
1636
+ guard: guard ?? null,
1637
+ handler: manifest.spec.handler,
1638
+ manifest,
1639
+ })).sort((a, b) => a.name.localeCompare(b.name));
1640
+ const triggers = Object.values(plan.triggers).map(({ name, manifest, target }) => {
1641
+ const source = manifest.spec.source;
1642
+ const targetAudience = developerAudience(plan.procedures[target]?.authorization?.all ?? []);
1643
+ return {
1644
+ name,
1645
+ target,
1646
+ audience: source.kind === "lifecycle" ? "system" : source.kind === "mcp" && source.surface === "staff" ? "staff" : targetAudience ?? "public",
1647
+ source,
1648
+ manifest,
1649
+ };
1650
+ }).sort((a, b) => a.name.localeCompare(b.name));
1651
+ const graph = {
1652
+ atoms: [
1653
+ ...Object.values(plan.schemas).map(({ name, manifest }) => ({ id: `Schema:${name}`, kind: "Schema", name, title: manifest.spec.title })),
1654
+ ...Object.values(plan.views).map(({ name, manifest, authorization }) => ({
1655
+ id: `View:${name}`,
1656
+ kind: "View",
1657
+ name,
1658
+ title: manifest.spec.title ?? null,
1659
+ audience: manifest.spec.surface === "staff" ? "staff" : developerAudience(authorization?.all ?? []) ?? "public",
1660
+ })),
1661
+ ...procedures.map(({ name, title, description, audience, handler }) => ({
1662
+ id: `Procedure:${name}`,
1663
+ kind: "Procedure",
1664
+ name,
1665
+ title,
1666
+ description,
1667
+ audience,
1668
+ handler,
1669
+ })),
1670
+ ...triggers.map(({ name, source, audience }) => ({
1671
+ id: `Trigger:${name}`,
1672
+ kind: "Trigger",
1673
+ name,
1674
+ title: null,
1675
+ audience,
1676
+ transport: source.kind,
1677
+ })),
1678
+ ].sort((a, b) => a.id.localeCompare(b.id)),
1679
+ relations: [
1680
+ ...Object.values(plan.schemas).flatMap(({ name, manifest, translationParent }) => [
1681
+ ...(translationParent ? [developerRelation(`Schema:${name}:translates:${translationParent}`, "translation-parent", `Schema:${name}`, `Schema:${translationParent}`, "/spec/translates/parent", translationParent)] : []),
1682
+ ...Object.entries(manifest.spec.schema.properties ?? {}).flatMap(([field, property]) => {
1683
+ const target = property[MANTLE_REF_KEYWORD];
1684
+ if (typeof target !== "string" || !schemasByName.has(target))
1685
+ return [];
1686
+ return [developerRelation(`Schema:${name}:field:${field}:${target}`, "schema-reference", `Schema:${name}`, `Schema:${target}`, `/spec/schema/properties/${jsonPointerSegment(field)}/x-mantle-ref`, target)];
1687
+ }),
1688
+ ]),
1689
+ ...Object.values(plan.views).flatMap(({ name, query, guard }) => [
1690
+ ...(query.kind === "declarative" ? [developerRelation(`View:${name}:from:${query.from}`, "view-source", `View:${name}`, `Schema:${query.from}`, "/spec/from", query.from)] : []),
1691
+ ...(guard ? [developerRelation(`View:${name}:guard:${guard}`, "authorization-guard", `View:${name}`, `Procedure:${guard}`, "/spec/requires/guard/procedure", guard)] : []),
1692
+ ]),
1693
+ ...Object.values(plan.procedures).flatMap(({ name, manifest, builtinSchema, collectionActionSchema, guard }) => [
1694
+ ...(builtinSchema ? [developerRelation(`Procedure:${name}:builtin:${builtinSchema}`, "procedure-schema", `Procedure:${name}`, `Schema:${builtinSchema}`, "/spec/handler/schema", builtinSchema)] : []),
1695
+ ...(collectionActionSchema ? [developerRelation(`Procedure:${name}:collectionAction:${collectionActionSchema}`, "collection-action", `Procedure:${name}`, `Schema:${collectionActionSchema}`, "/spec/uiSchema/collectionAction", collectionActionSchema)] : []),
1696
+ ...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)),
1697
+ ...(guard ? [developerRelation(`Procedure:${name}:guard:${guard}`, "authorization-guard", `Procedure:${name}`, `Procedure:${guard}`, "/spec/requires/guard/procedure", guard)] : []),
1698
+ ]),
1699
+ ...Object.values(plan.triggers).flatMap(({ name, target, lifecycleSchema }) => [
1700
+ developerRelation(`Trigger:${name}:target:${target}`, "trigger-target", `Trigger:${name}`, `Procedure:${target}`, "/spec/target/procedure", target),
1701
+ ...(lifecycleSchema ? [developerRelation(`Trigger:${name}:source:${lifecycleSchema}`, "lifecycle-source", `Trigger:${name}`, `Schema:${lifecycleSchema}`, "/spec/source/schema", lifecycleSchema)] : []),
1702
+ ]),
1703
+ ].sort((a, b) => a.id.localeCompare(b.id)),
1704
+ };
1705
+ return {
1706
+ dataModel: {
1707
+ schemas: Object.values(plan.schemas).map(({ name, manifest }) => ({
1708
+ name,
1709
+ title: manifest.spec.title,
1710
+ lifecycle: resolveLifecycle(manifest),
1711
+ localized: manifest.spec.localized ?? false,
1712
+ translates: manifest.spec.translates ?? null,
1713
+ schema: manifest.spec.schema,
1714
+ uniqueIndexes: manifest.spec.uniqueIndexes ?? [],
1715
+ indexes: manifest.spec.indexes ?? [],
1716
+ searchableFields: manifest.spec.searchableFields ?? [],
1717
+ manifest,
1718
+ })).sort((a, b) => a.name.localeCompare(b.name)),
1719
+ views: Object.values(plan.views).map(({ name, manifest, query, authorization, guard }) => ({
1720
+ name,
1721
+ title: manifest.spec.title ?? null,
1722
+ surface: manifest.spec.surface,
1723
+ query,
1724
+ authorization: authorization?.all ?? [],
1725
+ guard: guard ?? null,
1726
+ manifest,
1727
+ })).sort((a, b) => a.name.localeCompare(b.name)),
1728
+ },
1729
+ logic: { triggers, procedures },
1730
+ interfaces: projectDeveloperInterfaces(plan),
1731
+ graph,
1732
+ };
1733
+ }
1734
+ function projectDeveloperInterfaces(plan) {
1735
+ const callable = projectCallableCapabilities(plan).map((capability) => ({
1736
+ kind: capability.kind,
1737
+ name: capability.name,
1738
+ target: capability.ownerName,
1739
+ surface: capability.surface,
1740
+ audience: developerAudience((capability.kind === "view"
1741
+ ? plan.views[capability.ownerName]?.authorization?.all
1742
+ : plan.procedures[capability.ownerName]?.authorization?.all) ?? []) ?? "public",
1743
+ title: capability.title ?? null,
1744
+ description: capability.description,
1745
+ input: capability.inputSchema,
1746
+ output: capability.kind === "procedure" ? capability.outputSchema : null,
1747
+ trigger: capability.kind === "procedure" ? capability.trigger : null,
1748
+ }));
1749
+ const http = [
1750
+ ...plan.httpRoutes.map((route) => {
1751
+ const procedure = plan.procedures[route.procedure];
1752
+ return {
1753
+ kind: "procedure",
1754
+ name: route.trigger,
1755
+ target: route.procedure,
1756
+ method: route.method,
1757
+ path: route.path,
1758
+ audience: developerAudience(procedure.authorization?.all ?? []) ?? "public",
1759
+ title: resolveLocalizedText(procedure.manifest.spec.title, "en") ?? null,
1760
+ description: resolveLocalizedText(procedure.manifest.spec.description, "en") ?? `Invoke Procedure '${route.procedure}'.`,
1761
+ input: procedure.manifest.spec.input,
1762
+ output: procedure.manifest.spec.output,
1763
+ };
1764
+ }),
1765
+ ...callable.filter((capability) => capability.kind === "view" && capability.surface === "public").map((capability) => {
1766
+ const view = plan.views[capability.target];
1767
+ return {
1768
+ kind: "view",
1769
+ name: capability.name,
1770
+ target: capability.target,
1771
+ method: "GET",
1772
+ path: `/api/views/${capability.target}`,
1773
+ audience: developerAudience(view.authorization?.all ?? []) ?? "public",
1774
+ title: capability.title,
1775
+ description: capability.description,
1776
+ input: capability.input,
1777
+ output: null,
1778
+ };
1779
+ }),
1780
+ ].sort((a, b) => `${a.path}\0${a.method}`.localeCompare(`${b.path}\0${b.method}`));
1781
+ return { http, callable };
1782
+ }
1420
1783
  export async function runMantleUseCase(operation, execute) {
1421
1784
  try {
1422
1785
  const result = await execute();