@aotter/mantle-cloudflare 0.0.11-alpha.73 → 0.1.0-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.
Files changed (67) hide show
  1. package/README.md +11 -5
  2. package/dist/auth/createAuth.d.ts +22 -10
  3. package/dist/auth/createAuth.d.ts.map +1 -1
  4. package/dist/auth/createAuth.js +69 -1
  5. package/dist/auth/createAuth.js.map +1 -1
  6. package/dist/auth/rejectCrossOriginMutation.d.ts +2 -0
  7. package/dist/auth/rejectCrossOriginMutation.d.ts.map +1 -0
  8. package/dist/auth/rejectCrossOriginMutation.js +15 -0
  9. package/dist/auth/rejectCrossOriginMutation.js.map +1 -0
  10. package/dist/bindings/D1DatabaseDriver.js +21 -13
  11. package/dist/bindings/D1DatabaseDriver.js.map +1 -1
  12. package/dist/bindings/conventionalBindings.d.ts +0 -1
  13. package/dist/bindings/conventionalBindings.d.ts.map +1 -1
  14. package/dist/bindings/conventionalBindings.js +0 -4
  15. package/dist/bindings/conventionalBindings.js.map +1 -1
  16. package/dist/bindings/index.d.ts +0 -1
  17. package/dist/bindings/index.d.ts.map +1 -1
  18. package/dist/bindings/index.js +0 -1
  19. package/dist/bindings/index.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/mount/bootRuntimeOnce.d.ts +1 -1
  23. package/dist/mount/bootRuntimeOnce.d.ts.map +1 -1
  24. package/dist/mount/bootRuntimeOnce.js +49 -5
  25. package/dist/mount/bootRuntimeOnce.js.map +1 -1
  26. package/dist/mount/cmsConfig.d.ts +3 -3
  27. package/dist/mount/cmsConfig.d.ts.map +1 -1
  28. package/dist/mount/mountMcp.d.ts +4 -4
  29. package/dist/mount/mountMcp.d.ts.map +1 -1
  30. package/dist/mount/mountMcp.js +34 -20
  31. package/dist/mount/mountMcp.js.map +1 -1
  32. package/dist/mount/mountPublicRoutes.d.ts +13 -18
  33. package/dist/mount/mountPublicRoutes.d.ts.map +1 -1
  34. package/dist/mount/mountPublicRoutes.js +39 -97
  35. package/dist/mount/mountPublicRoutes.js.map +1 -1
  36. package/dist/mount/mountServerEndpoints.d.ts +3 -4
  37. package/dist/mount/mountServerEndpoints.d.ts.map +1 -1
  38. package/dist/mount/mountServerEndpoints.js +234 -140
  39. package/dist/mount/mountServerEndpoints.js.map +1 -1
  40. package/dist/mount/resolveCaller.d.ts +2 -1
  41. package/dist/mount/resolveCaller.d.ts.map +1 -1
  42. package/dist/mount/resolveCaller.js +43 -7
  43. package/dist/mount/resolveCaller.js.map +1 -1
  44. package/dist/oauth/consentHtml.d.ts +1 -1
  45. package/dist/oauth/consentHtml.d.ts.map +1 -1
  46. package/dist/oauth/consentHtml.js +15 -13
  47. package/dist/oauth/consentHtml.js.map +1 -1
  48. package/dist/oauth/index.d.ts +1 -1
  49. package/dist/oauth/index.d.ts.map +1 -1
  50. package/dist/oauth/index.js.map +1 -1
  51. package/dist/oauth/mountOAuth.d.ts +0 -7
  52. package/dist/oauth/mountOAuth.d.ts.map +1 -1
  53. package/dist/oauth/mountOAuth.js +7 -15
  54. package/dist/oauth/mountOAuth.js.map +1 -1
  55. package/dist/oauth/oauthSingleton.d.ts +2 -3
  56. package/dist/oauth/oauthSingleton.d.ts.map +1 -1
  57. package/dist/oauth/oauthSingleton.js +6 -0
  58. package/dist/oauth/oauthSingleton.js.map +1 -1
  59. package/dist/worker/createMantleWorker.d.ts +4 -2
  60. package/dist/worker/createMantleWorker.d.ts.map +1 -1
  61. package/dist/worker/createMantleWorker.js +16 -2
  62. package/dist/worker/createMantleWorker.js.map +1 -1
  63. package/package.json +13 -13
  64. package/dist/bindings/KvCacheBinding.d.ts +0 -14
  65. package/dist/bindings/KvCacheBinding.d.ts.map +0 -1
  66. package/dist/bindings/KvCacheBinding.js +0 -27
  67. package/dist/bindings/KvCacheBinding.js.map +0 -1
@@ -1,16 +1,16 @@
1
- import { DiagnosticError, HTTP_STATUS_BY_CODE, MANTLE_REF_KEYWORD, MCP_HINT_KEYWORD, VIEW_PARAMS_RESERVED, isMediaMcpHint, redactForWire, runtimeDiagnostic, } from "@aotter/mantle-spec";
1
+ import { DiagnosticError, HTTP_STATUS_BY_CODE, MANTLE_REF_KEYWORD, MCP_HINT_KEYWORD, VIEW_PARAMS_RESERVED, isMediaMcpHint, meetsRole, redactForWire, runtimeDiagnostic, checkSchemaListFilter, schemaSortableFields, } from "@aotter/mantle-spec";
2
2
  import { ViewParamCoercionError, coerceViewParams, compilePathMatcher, evaluateAuthAll, } from "@aotter/mantle-runtime";
3
3
  import { indexHtml } from "@aotter/mantle-admin-ui";
4
4
  import { resolveCaller } from "./resolveCaller.js";
5
5
  import { runMantleUseCase } from "./runMantleUseCase.js";
6
- import { STAFF_ROLE_SET } from "../auth/createAuth.js";
6
+ import { decodeMemberCursor, STAFF_ROLE_SET, } from "../auth/createAuth.js";
7
+ import { rejectCrossOriginMutation } from "../auth/rejectCrossOriginMutation.js";
7
8
  import { AOTTER_FAVICON_SVG } from "../assets/aotterFavicon.js";
8
9
  const [PAGE_PARAM, SHOW_PARAM] = VIEW_PARAMS_RESERVED;
9
10
  /** Mount HTTP Triggers + Views + the Better Auth admin surface.
10
- * HTTP Trigger bearer-token authentication is delegated to the OAuth
11
- * provider lib (via `createMcpApiHandler`) — if a Trigger needs
12
- * identity, route it under an MCP `apiHandler` instead of a Hono
13
- * catch-all. */
11
+ * HTTP Triggers resolve configured credentials and sessions through
12
+ * `resolveCaller`; the target Procedure's `requires.auth` owns
13
+ * authorization. */
14
14
  export function mountServerEndpoints(app, ref) {
15
15
  for (const t of ref.manifests) {
16
16
  if (t.kind !== "Trigger")
@@ -89,10 +89,10 @@ function mountAdminBetterAuth(app, ref, auth) {
89
89
  "/admin/c/:collection",
90
90
  "/admin/c/:collection/:id",
91
91
  "/admin/media",
92
- "/admin/approvals",
93
92
  "/admin/preferences",
94
93
  "/admin/settings",
95
94
  "/admin/staff",
95
+ "/admin/members",
96
96
  "/admin/ops",
97
97
  "/admin/views/:name",
98
98
  ]) {
@@ -146,19 +146,47 @@ function mountAdminBetterAuth(app, ref, auth) {
146
146
  return body(c, gate);
147
147
  });
148
148
  };
149
- // Staff management is owner-only — a step above `guarded` (any
150
- // staff). Role semantics follow StaffRoleHierarchy: only `owner`
151
- // "manages staff".
152
- const ownerGuarded = (method, path, body) => {
149
+ const roleGuarded = (method, path, minimumRole, body) => {
153
150
  guarded(method, path, (c, gate) => {
154
- if (gate.role !== "owner")
155
- return adminNotOwner(c, path);
151
+ if (!meetsRole(gate.role, minimumRole)) {
152
+ return adminInsufficientRole(c, path, minimumRole);
153
+ }
156
154
  return body(c, gate);
157
155
  });
158
156
  };
159
- guarded("get", "/admin/api/me", (_c, gate) => Response.json({ login: gate.login, role: gate.role, userId: gate.userId }));
160
- ownerGuarded("get", "/admin/api/staff", async () => Response.json({ users: await auth.listUsers() }));
161
- ownerGuarded("patch", "/admin/api/staff/:id/role", async (c, gate) => {
157
+ guarded("get", "/admin/api/me", (_c, gate) => Response.json({ login: gate.login, role: gate.role, userId: gate.userId, image: gate.image }));
158
+ roleGuarded("get", "/admin/api/staff", "owner", async () => Response.json({ users: await auth.listUsers() }));
159
+ roleGuarded("get", "/admin/api/members", "editor", async (c) => {
160
+ const rawLimit = c.req.query("limit");
161
+ const limit = rawLimit === undefined ? 50 : Number(rawLimit);
162
+ const cursor = c.req.query("cursor") || undefined;
163
+ const search = c.req.query("search")?.trim() || undefined;
164
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100 ||
165
+ (cursor && !decodeMemberCursor(cursor)) || (search?.length ?? 0) > 200) {
166
+ return Response.json({
167
+ ok: false,
168
+ diagnostic: runtimeDiagnostic({
169
+ code: "INPUT_VALIDATION_FAILED",
170
+ severity: "error",
171
+ path: "GET /admin/api/members",
172
+ expected: "limit 1..100, a valid cursor, and search up to 200 characters",
173
+ message: "Member list parameters are invalid.",
174
+ }),
175
+ }, { status: 400 });
176
+ }
177
+ const result = await auth.listMembers({
178
+ limit,
179
+ search,
180
+ cursor,
181
+ cursorDirection: c.req.query("cursor_direction") === "backward" ? "backward" : "forward",
182
+ });
183
+ return Response.json({
184
+ items: result.items,
185
+ previous_cursor: result.previousCursor,
186
+ next_cursor: result.nextCursor,
187
+ });
188
+ });
189
+ roleGuarded("patch", "/admin/api/staff/:id/role", "owner", async (c, gate) => {
162
190
  const userId = c.req.param("id") ?? "";
163
191
  const body = (await c.req.raw.json().catch(() => ({})));
164
192
  const role = body.role === null ? null : typeof body.role === "string" ? body.role : undefined;
@@ -204,7 +232,7 @@ function mountAdminBetterAuth(app, ref, auth) {
204
232
  }
205
233
  return Response.json({ ok: true });
206
234
  });
207
- ownerGuarded("post", "/admin/api/staff/invitations", async (c) => {
235
+ roleGuarded("post", "/admin/api/staff/invitations", "owner", async (c, gate) => {
208
236
  const body = (await c.req.raw.json().catch(() => ({})));
209
237
  const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
210
238
  const role = typeof body.role === "string" ? body.role : "";
@@ -222,21 +250,23 @@ function mountAdminBetterAuth(app, ref, auth) {
222
250
  }
223
251
  const result = await auth.inviteUser(email, role);
224
252
  if (result.kind === "exists") {
225
- return Response.json({
226
- ok: false,
227
- userId: result.id,
228
- diagnostic: runtimeDiagnostic({
229
- code: "CONFLICT",
230
- severity: "error",
231
- path: "POST /admin/api/staff/invitations",
232
- expected: "an email without an existing user row",
233
- message: "That email already has an account — adjust its role in the staff list instead.",
234
- }),
235
- }, { status: 409 });
253
+ if (result.id === gate.userId) {
254
+ return Response.json({
255
+ ok: false,
256
+ diagnostic: runtimeDiagnostic({
257
+ code: "AUTH_DENIED",
258
+ severity: "error",
259
+ path: "POST /admin/api/staff/invitations",
260
+ expected: "an email other than the caller's",
261
+ message: "You cannot change your own role.",
262
+ }),
263
+ }, { status: 403 });
264
+ }
265
+ await auth.setUserRole(result.id, role);
236
266
  }
237
267
  return Response.json({ ok: true, userId: result.id });
238
268
  });
239
- ownerGuarded("delete", "/admin/api/staff/invitations/:id", async (c) => {
269
+ roleGuarded("delete", "/admin/api/staff/invitations/:id", "owner", async (c) => {
240
270
  const revoked = await auth.revokeInvite(c.req.param("id") ?? "");
241
271
  if (!revoked) {
242
272
  return Response.json({
@@ -267,8 +297,8 @@ function mountAdminBetterAuth(app, ref, auth) {
267
297
  return handleViewRequest(c.req.raw, runtime, viewName, ref, c.env, waitUntil, "/admin/api/views");
268
298
  });
269
299
  }
270
- guarded("get", "/admin/api/operations", () => Response.json({
271
- operations: operations.map((op) => ({
300
+ guarded("get", "/admin/api/operations", (c, gate) => Response.json({
301
+ operations: operations.filter((op) => evaluateAuthAll(op.procedure.spec.requires, adminHandlerContext(c, gate), `GET /admin/api/operations/${op.name}`, "runtime") === null).map((op) => ({
272
302
  name: op.name,
273
303
  title: op.title,
274
304
  description: op.description,
@@ -315,19 +345,19 @@ function mountAdminBetterAuth(app, ref, auth) {
315
345
  });
316
346
  guarded("get", "/admin/api/site", async (c) => {
317
347
  const runtime = await ref.get();
318
- const site = await runtime.siteConfig.load();
319
- const url = new URL(c.req.url);
348
+ const { origin, ...site } = await runtime.siteConfig.load();
349
+ const publicUrl = origin || new URL(c.req.url).origin;
320
350
  return Response.json({
321
351
  ...site,
322
- publicUrl: site.origin || url.origin,
323
- mcpUrl: `${url.origin}/mcp/staff`,
352
+ publicUrl,
353
+ mcpUrl: `${publicUrl}/mcp/staff`,
324
354
  });
325
355
  });
326
- guarded("get", "/admin/api/site-settings", async () => runMantleUseCase("GET /admin/api/site-settings", async () => {
356
+ roleGuarded("get", "/admin/api/site-settings", "owner", async () => runMantleUseCase("GET /admin/api/site-settings", async () => {
327
357
  const runtime = await ref.get();
328
358
  return adminSiteSettings(await runtime.siteConfig.load());
329
359
  }));
330
- guarded("patch", "/admin/api/site-settings", async (c) => runMantleUseCase("PATCH /admin/api/site-settings", async () => {
360
+ roleGuarded("patch", "/admin/api/site-settings", "owner", async (c) => runMantleUseCase("PATCH /admin/api/site-settings", async () => {
331
361
  const runtime = await ref.get();
332
362
  const body = (await c.req.raw.json().catch(() => ({})));
333
363
  const site = await runtime.updateSiteSettings.execute({
@@ -357,6 +387,21 @@ function mountAdminBetterAuth(app, ref, auth) {
357
387
  const rawLimit = c.req.query("limit");
358
388
  const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : NaN;
359
389
  const statusQuery = c.req.query("status");
390
+ const sortDirection = c.req.query("direction") === "asc" ? "asc" : "desc";
391
+ const filterField = c.req.query("filter_field");
392
+ const filterValue = c.req.query("filter_value");
393
+ if (Boolean(filterField) !== Boolean(filterValue)) {
394
+ return Response.json({
395
+ ok: false,
396
+ diagnostic: runtimeDiagnostic({
397
+ code: "INPUT_VALIDATION_FAILED",
398
+ severity: "error",
399
+ path: "GET /admin/api/entries",
400
+ expected: "filter_field and filter_value together",
401
+ message: "List filters require both `filter_field` and `filter_value`.",
402
+ }),
403
+ }, { status: 400 });
404
+ }
360
405
  // Admin pagination needs the cursored shape — `executePage` returns
361
406
  // `{ rows, nextCursor? }`. `execute()` is the flat-array variant
362
407
  // for app code.
@@ -365,10 +410,57 @@ function mountAdminBetterAuth(app, ref, auth) {
365
410
  status: statusQuery && statusQuery !== "all" ? statusQuery : undefined,
366
411
  limit: Number.isFinite(parsedLimit) ? parsedLimit : 99,
367
412
  cursor: c.req.query("cursor") ?? undefined,
413
+ cursorDirection: c.req.query("cursor_direction") === "backward" ? "backward" : "forward",
368
414
  search: c.req.query("search") || undefined,
415
+ filter: filterField && filterValue
416
+ ? { field: filterField, value: filterValue }
417
+ : undefined,
418
+ sort: {
419
+ field: c.req.query("sort") || "updatedAt",
420
+ direction: sortDirection,
421
+ },
422
+ });
423
+ const translationLocales = new Map();
424
+ const translationSchemas = schemas.filter((schema) => schema.spec.translates?.parent === collection);
425
+ for (const schema of translationSchemas) {
426
+ const field = schema.spec.translates.on;
427
+ const parentValues = new Map();
428
+ for (const row of result.rows) {
429
+ const value = primitiveJoinValue(row.data[field]);
430
+ if (value !== null)
431
+ parentValues.set(joinValueKey(value), value);
432
+ }
433
+ const translations = await runtime.entryReader.readByDataFieldIn({
434
+ collection: schema.metadata.name,
435
+ field,
436
+ values: [...parentValues.values()],
437
+ });
438
+ const localesByValue = new Map();
439
+ for (const entry of translations) {
440
+ const value = primitiveJoinValue(entry.data[field]);
441
+ if (value === null || !entry.locale)
442
+ continue;
443
+ const key = joinValueKey(value);
444
+ const locales = localesByValue.get(key) ?? new Set();
445
+ locales.add(entry.locale);
446
+ localesByValue.set(key, locales);
447
+ }
448
+ for (const row of result.rows) {
449
+ const value = primitiveJoinValue(row.data[field]);
450
+ if (value === null)
451
+ continue;
452
+ const locales = translationLocales.get(row.id) ?? new Set();
453
+ for (const locale of localesByValue.get(joinValueKey(value)) ?? [])
454
+ locales.add(locale);
455
+ translationLocales.set(row.id, locales);
456
+ }
457
+ }
458
+ const items = result.rows.map((row) => adminListItem(row, schemasByName, [...(translationLocales.get(row.id) ?? [])]));
459
+ return Response.json({
460
+ items,
461
+ previous_cursor: result.previousCursor ?? null,
462
+ next_cursor: result.nextCursor ?? null,
369
463
  });
370
- const items = result.rows.map((row) => adminListItem(row, schemasByName));
371
- return Response.json({ items, next_cursor: result.nextCursor ?? null });
372
464
  });
373
465
  guarded("get", "/admin/api/entries/export", async (c) => {
374
466
  const collection = c.req.query("collection");
@@ -426,6 +518,10 @@ function mountAdminBetterAuth(app, ref, auth) {
426
518
  message: "A non-empty `collection` is required.",
427
519
  }));
428
520
  }
521
+ if (gate.role === "contributor" &&
522
+ (schemasByName.get(body.collection)?.spec.lifecycle ?? "publishing") === "operational") {
523
+ throw new DiagnosticError(adminRoleDiagnostic("POST /admin/api/entries", "editor", "Contributors can create drafts, not operational records."));
524
+ }
429
525
  const row = await runtime.createDraft.execute({
430
526
  collection: body.collection,
431
527
  data: objectField(body.data),
@@ -438,6 +534,13 @@ function mountAdminBetterAuth(app, ref, auth) {
438
534
  guarded("patch", "/admin/api/entries/:id", async (c, gate) => runMantleUseCase(`PATCH /admin/api/entries/${c.req.param("id")}`, async () => {
439
535
  const runtime = await ref.get();
440
536
  const id = c.req.param("id");
537
+ if (gate.role === "contributor") {
538
+ const current = await runtime.getEntry.execute({ id });
539
+ const lifecycle = schemasByName.get(current.collection)?.spec.lifecycle ?? "publishing";
540
+ if (lifecycle === "operational" || current.status !== "draft") {
541
+ throw new DiagnosticError(adminRoleDiagnostic(`PATCH /admin/api/entries/${id}`, "editor", "Contributors can edit drafts only."));
542
+ }
543
+ }
441
544
  const body = (await c.req.raw.json().catch(() => ({})));
442
545
  const updated = await runtime.updateDraft.execute({
443
546
  id,
@@ -448,7 +551,7 @@ function mountAdminBetterAuth(app, ref, auth) {
448
551
  });
449
552
  return entryEditorPayload(runtime, updated, schemas);
450
553
  }));
451
- guarded("post", "/admin/api/entries/:id/publish", async (c, gate) => runMantleUseCase(`POST /admin/api/entries/${c.req.param("id")}/publish`, async () => {
554
+ roleGuarded("post", "/admin/api/entries/:id/publish", "editor", async (c, gate) => runMantleUseCase(`POST /admin/api/entries/${c.req.param("id")}/publish`, async () => {
452
555
  const runtime = await ref.get();
453
556
  const id = c.req.param("id");
454
557
  const body = await c.req.raw.json().catch(() => ({}));
@@ -459,7 +562,7 @@ function mountAdminBetterAuth(app, ref, auth) {
459
562
  });
460
563
  return entryEditorPayload(runtime, row, schemas);
461
564
  }));
462
- guarded("post", "/admin/api/entries/:id/unpublish", async (c, gate) => runMantleUseCase(`POST /admin/api/entries/${c.req.param("id")}/unpublish`, async () => {
565
+ roleGuarded("post", "/admin/api/entries/:id/unpublish", "editor", async (c, gate) => runMantleUseCase(`POST /admin/api/entries/${c.req.param("id")}/unpublish`, async () => {
463
566
  const runtime = await ref.get();
464
567
  const id = c.req.param("id");
465
568
  const body = await c.req.raw.json().catch(() => ({}));
@@ -470,7 +573,7 @@ function mountAdminBetterAuth(app, ref, auth) {
470
573
  });
471
574
  return entryEditorPayload(runtime, row, schemas);
472
575
  }));
473
- guarded("delete", "/admin/api/entries/:id", async (c, gate) => runMantleUseCase(`DELETE /admin/api/entries/${c.req.param("id")}`, async () => {
576
+ roleGuarded("delete", "/admin/api/entries/:id", "editor", async (c, gate) => runMantleUseCase(`DELETE /admin/api/entries/${c.req.param("id")}`, async () => {
474
577
  const runtime = await ref.get();
475
578
  const id = c.req.param("id");
476
579
  const body = await c.req.raw.json().catch(() => ({}));
@@ -485,7 +588,7 @@ function mountAdminBetterAuth(app, ref, auth) {
485
588
  // directly to R2 S3 (Worker bypassed) → POST /uploads/:groupId/commit.
486
589
  const MEDIA_UPLOADS_PATH = "/admin/api/media/uploads";
487
590
  const MEDIA_COMMIT_PATH = "/admin/api/media/uploads/:uploadGroupId/commit";
488
- guarded("post", MEDIA_UPLOADS_PATH, async (c) => {
591
+ roleGuarded("post", MEDIA_UPLOADS_PATH, "editor", async (c) => {
489
592
  const runtime = await ref.get();
490
593
  const media = runtime.media;
491
594
  if (!media)
@@ -547,7 +650,7 @@ function mountAdminBetterAuth(app, ref, auth) {
547
650
  caption: typeof body.caption === "string" ? body.caption : undefined,
548
651
  }));
549
652
  });
550
- guarded("post", MEDIA_COMMIT_PATH, async (c) => {
653
+ roleGuarded("post", MEDIA_COMMIT_PATH, "editor", async (c) => {
551
654
  const runtime = await ref.get();
552
655
  const media = runtime.media;
553
656
  if (!media)
@@ -567,7 +670,7 @@ function mountAdminBetterAuth(app, ref, auth) {
567
670
  // `mediaStorage` is bound (mirrors the upload handlers above).
568
671
  const MEDIA_LIST_PATH = "/admin/api/media";
569
672
  const MEDIA_ASSET_PATH = "/admin/api/media/:id";
570
- guarded("get", MEDIA_LIST_PATH, async (c) => {
673
+ roleGuarded("get", MEDIA_LIST_PATH, "editor", async (c) => {
571
674
  const runtime = await ref.get();
572
675
  const media = runtime.media;
573
676
  if (!media)
@@ -586,7 +689,7 @@ function mountAdminBetterAuth(app, ref, auth) {
586
689
  };
587
690
  });
588
691
  });
589
- guarded("get", MEDIA_ASSET_PATH, async (c) => {
692
+ roleGuarded("get", MEDIA_ASSET_PATH, "editor", async (c) => {
590
693
  const runtime = await ref.get();
591
694
  const media = runtime.media;
592
695
  if (!media)
@@ -594,7 +697,7 @@ function mountAdminBetterAuth(app, ref, auth) {
594
697
  const id = c.req.param("id");
595
698
  return runMantleUseCase(`GET ${MEDIA_ASSET_PATH}`, async () => adminMediaItem(await media.getAsset.execute(id)));
596
699
  });
597
- guarded("patch", MEDIA_ASSET_PATH, async (c) => {
700
+ roleGuarded("patch", MEDIA_ASSET_PATH, "editor", async (c) => {
598
701
  const runtime = await ref.get();
599
702
  const media = runtime.media;
600
703
  if (!media)
@@ -620,7 +723,7 @@ function mountAdminBetterAuth(app, ref, auth) {
620
723
  caption: typeof body.caption === "string" ? body.caption : undefined,
621
724
  })));
622
725
  });
623
- guarded("delete", MEDIA_ASSET_PATH, async (c) => {
726
+ roleGuarded("delete", MEDIA_ASSET_PATH, "editor", async (c) => {
624
727
  const runtime = await ref.get();
625
728
  const media = runtime.media;
626
729
  if (!media)
@@ -827,7 +930,7 @@ function titleFieldKey(data, schema) {
827
930
  * next to the system column (#443). NAME-based only, on purpose: no
828
931
  * fuzzy/semantic matching, just these two well-known reserved names. */
829
932
  const DATA_PREVIEW_SYSTEM_COLUMN_NAMES = new Set(["updatedAt", "createdAt"]);
830
- /** Operational (`lifecycle: none`) collections have no title/status
933
+ /** Operational collections have no title/status
831
934
  * workflow worth a dedicated column — instead the admin list shows up
832
935
  * to 3 raw data columns. Mirrors the client-side column-picking rule
833
936
  * in `collection-view.tsx`: first 3 `required` properties, skipping
@@ -835,7 +938,7 @@ const DATA_PREVIEW_SYSTEM_COLUMN_NAMES = new Set(["updatedAt", "createdAt"]);
835
938
  * above (#443). Kept small on the wire on purpose — this is a
836
939
  * preview, not the full entry. */
837
940
  function adminDataPreview(data, manifest) {
838
- if (!manifest || manifest.spec.lifecycle !== "none")
941
+ if (!manifest || manifest.spec.lifecycle !== "operational")
839
942
  return undefined;
840
943
  const schema = manifest.spec.schema;
841
944
  // Schema-stable skip (not per-row): rows with a blank title field
@@ -874,7 +977,7 @@ function isStringTypedSchema(schema) {
874
977
  const types = Array.isArray(rawType) ? rawType : rawType ? [rawType] : [];
875
978
  return types.includes("string");
876
979
  }
877
- function adminListItem(row, schemasByName) {
980
+ function adminListItem(row, schemasByName, translationLocales = []) {
878
981
  const manifest = schemasByName.get(row.collection);
879
982
  return {
880
983
  id: row.id,
@@ -884,6 +987,7 @@ function adminListItem(row, schemasByName) {
884
987
  version: row.version,
885
988
  title: adminEntryTitle(row.data, manifest?.spec.schema),
886
989
  updated_at: row.updatedAt,
990
+ translation_locales: translationLocales,
887
991
  data_preview: adminDataPreview(row.data, manifest),
888
992
  };
889
993
  }
@@ -937,6 +1041,7 @@ async function entryEditorPayload(runtime, row, schemas) {
937
1041
  return {
938
1042
  collection: adminEditorCollection(schema, schemas),
939
1043
  entry: adminEditorEntry(row),
1044
+ parentEntryId: await parentEntryId(runtime, schema, row, schemas),
940
1045
  related,
941
1046
  };
942
1047
  }
@@ -945,7 +1050,7 @@ function adminEditorCollection(schema, schemas) {
945
1050
  name: schema.metadata.name,
946
1051
  title: schema.spec.title,
947
1052
  description: schema.spec.description ?? null,
948
- lifecycle: schema.spec.lifecycle ?? "simple",
1053
+ lifecycle: schema.spec.lifecycle ?? "publishing",
949
1054
  parent: collectionParentFor(schema, schemas),
950
1055
  hasTranslations: schemas.some((candidate) => candidate.spec.translates?.parent === schema.metadata.name),
951
1056
  localized: schema.spec.localized ?? Boolean(schema.spec.translates),
@@ -953,6 +1058,8 @@ function adminEditorCollection(schema, schemas) {
953
1058
  schema: schema.spec.schema,
954
1059
  uiSchema: schema.spec.uiSchema ?? null,
955
1060
  mediaFields: mediaFieldsForCollection(schema, schemas),
1061
+ sortableFields: schemaSortableFields(schema),
1062
+ filter: checkSchemaListFilter(schema).filter,
956
1063
  };
957
1064
  }
958
1065
  function adminEditorEntry(row) {
@@ -973,7 +1080,9 @@ async function relatedEntrySections(runtime, parentSchema, parentRow, schemas) {
973
1080
  const childSchema = schemas.find((schema) => schema.metadata.name === relationship.collection);
974
1081
  if (!childSchema)
975
1082
  continue;
976
- const entries = await entriesByDataValue(runtime, relationship.collection, relationship.childField, relationship.parentValue);
1083
+ const entries = relationship.parentValue === null
1084
+ ? []
1085
+ : await entriesByDataValue(runtime, relationship.collection, relationship.childField, relationship.parentValue);
977
1086
  sections.push({
978
1087
  collection: adminEditorCollection(childSchema, schemas),
979
1088
  relationship: {
@@ -989,12 +1098,11 @@ async function relatedEntrySections(runtime, parentSchema, parentRow, schemas) {
989
1098
  }
990
1099
  function discoverChildRelationships(parentSchema, parentRow, schemas) {
991
1100
  const parentName = parentSchema.metadata.name;
992
- const parentProps = parentSchema.spec.schema.properties ?? {};
993
1101
  const relationships = [];
994
1102
  const seen = new Set();
995
- const add = (childSchema, kind, parentField, childField) => {
996
- const parentValue = primitiveJoinValue(parentRow.data[parentField]);
997
- if (parentValue == null)
1103
+ const add = (childSchema, kind, parentField, childField, rawParentValue) => {
1104
+ const parentValue = primitiveJoinValue(rawParentValue);
1105
+ if (parentValue == null && kind !== "translation")
998
1106
  return;
999
1107
  const key = `${childSchema.metadata.name}:${kind}:${parentField}:${childField}:${String(parentValue)}`;
1000
1108
  if (seen.has(key))
@@ -1008,45 +1116,27 @@ function discoverChildRelationships(parentSchema, parentRow, schemas) {
1008
1116
  parentValue,
1009
1117
  });
1010
1118
  };
1119
+ const translates = parentSchema.spec.translates;
1120
+ if (translates) {
1121
+ add(parentSchema, "translation", translates.on, translates.on, parentRow.data[translates.on]);
1122
+ }
1011
1123
  for (const childSchema of schemas) {
1012
1124
  if (childSchema.metadata.name === parentName)
1013
1125
  continue;
1014
1126
  const childProps = childSchema.spec.schema.properties ?? {};
1015
1127
  const translates = childSchema.spec.translates;
1016
1128
  if (translates?.parent === parentName) {
1017
- add(childSchema, "translation", translates.on, translates.on);
1129
+ add(childSchema, "translation", translates.on, translates.on, parentRow.data[translates.on]);
1018
1130
  continue;
1019
1131
  }
1020
- for (const [childField] of Object.entries(childProps)) {
1021
- const parentField = conventionalParentField(parentName, childField, parentProps);
1022
- if (parentField)
1023
- add(childSchema, "field", parentField, childField);
1132
+ for (const [childField, childProperty] of Object.entries(childProps)) {
1133
+ if (childProperty[MANTLE_REF_KEYWORD] === parentName) {
1134
+ add(childSchema, "field", "id", childField, parentRow.id);
1135
+ }
1024
1136
  }
1025
1137
  }
1026
1138
  return relationships;
1027
1139
  }
1028
- function conventionalParentField(parentCollectionName, childField, parentProps) {
1029
- const bases = new Set([
1030
- camelCaseIdentifier(parentCollectionName),
1031
- singularizeIdentifier(camelCaseIdentifier(parentCollectionName)),
1032
- ]);
1033
- for (const base of bases) {
1034
- for (const [parentField, parentSchema] of Object.entries(parentProps)) {
1035
- if (!isPrimitiveJoinSchema(parentSchema))
1036
- continue;
1037
- if (childField === `${base}${capitalizeIdentifier(parentField)}`)
1038
- return parentField;
1039
- }
1040
- }
1041
- return null;
1042
- }
1043
- function isPrimitiveJoinSchema(schema) {
1044
- const rawType = schema.type;
1045
- const types = Array.isArray(rawType) ? rawType : rawType ? [rawType] : [];
1046
- if (types.length === 0 && schema.enum)
1047
- return true;
1048
- return types.some((type) => type === "string" || type === "number" || type === "integer" || type === "boolean");
1049
- }
1050
1140
  function collectionParentFor(childSchema, schemas) {
1051
1141
  if (childSchema.spec.translates) {
1052
1142
  return {
@@ -1060,27 +1150,30 @@ function collectionParentFor(childSchema, schemas) {
1060
1150
  // parent, so it's buried under the parent in the sidebar). Optional
1061
1151
  // ref = weak reference — the collection stays top-level.
1062
1152
  const childRequired = new Set(childSchema.spec.schema.required ?? []);
1063
- for (const parentSchema of schemas) {
1064
- if (parentSchema.metadata.name === childSchema.metadata.name)
1065
- continue;
1066
- if (parentSchema.spec.translates)
1153
+ const schemaNames = new Set(schemas
1154
+ .filter((schema) => !schema.spec.translates)
1155
+ .map((schema) => schema.metadata.name));
1156
+ for (const [childField, childProperty] of Object.entries(childProps)) {
1157
+ if (!childRequired.has(childField))
1067
1158
  continue;
1068
- const parentProps = parentSchema.spec.schema.properties ?? {};
1069
- for (const [childField] of Object.entries(childProps)) {
1070
- if (!childRequired.has(childField))
1071
- continue;
1072
- const parentField = conventionalParentField(parentSchema.metadata.name, childField, parentProps);
1073
- if (parentField) {
1074
- return {
1075
- collection: parentSchema.metadata.name,
1076
- parentField,
1077
- childField,
1078
- };
1079
- }
1159
+ const parent = childProperty[MANTLE_REF_KEYWORD];
1160
+ if (typeof parent === "string" && schemaNames.has(parent)) {
1161
+ return { collection: parent, parentField: "id", childField };
1080
1162
  }
1081
1163
  }
1082
1164
  return null;
1083
1165
  }
1166
+ async function parentEntryId(runtime, childSchema, childRow, schemas) {
1167
+ const parent = collectionParentFor(childSchema, schemas);
1168
+ if (!parent)
1169
+ return null;
1170
+ const value = primitiveJoinValue(childRow.data[parent.childField]);
1171
+ if (value === null)
1172
+ return null;
1173
+ if (parent.parentField === "id" && typeof value === "string")
1174
+ return value;
1175
+ return (await entriesByDataValue(runtime, parent.collection, parent.parentField, value))[0]?.id ?? null;
1176
+ }
1084
1177
  function primitiveJoinValue(value) {
1085
1178
  if (typeof value === "string" && value !== "")
1086
1179
  return value;
@@ -1090,27 +1183,8 @@ function primitiveJoinValue(value) {
1090
1183
  return value;
1091
1184
  return null;
1092
1185
  }
1093
- function camelCaseIdentifier(value) {
1094
- return value
1095
- .split(/[^a-zA-Z0-9]+/)
1096
- .filter(Boolean)
1097
- .map((part, index) => {
1098
- const lower = part.charAt(0).toLowerCase() + part.slice(1);
1099
- return index === 0 ? lower : capitalizeIdentifier(lower);
1100
- })
1101
- .join("");
1102
- }
1103
- function singularizeIdentifier(value) {
1104
- if (value.endsWith("ies"))
1105
- return `${value.slice(0, -3)}y`;
1106
- if (value.endsWith("ses"))
1107
- return value.slice(0, -2);
1108
- if (value.endsWith("s") && value.length > 1)
1109
- return value.slice(0, -1);
1110
- return value;
1111
- }
1112
- function capitalizeIdentifier(value) {
1113
- return value.charAt(0).toUpperCase() + value.slice(1);
1186
+ function joinValueKey(value) {
1187
+ return `${typeof value}:${String(value)}`;
1114
1188
  }
1115
1189
  async function entriesByDataValue(runtime, collection, field, value) {
1116
1190
  return runtime.entryReader.findManyByDataField({
@@ -1122,7 +1196,9 @@ async function entriesByDataValue(runtime, collection, field, value) {
1122
1196
  }
1123
1197
  function adminSiteSettings(site) {
1124
1198
  return {
1125
- ...site,
1199
+ brand: site.brand,
1200
+ title: site.title,
1201
+ description: site.description,
1126
1202
  ga4MeasurementId: site.ga4MeasurementId ?? "",
1127
1203
  facebookPixelId: site.facebookPixelId ?? "",
1128
1204
  };
@@ -1174,6 +1250,7 @@ async function readStaffGate(c, auth) {
1174
1250
  kind: "ok",
1175
1251
  userId: session.user.id,
1176
1252
  login,
1253
+ image: session.user.image ?? null,
1177
1254
  role: role,
1178
1255
  sessionId: session.session.id,
1179
1256
  };
@@ -1192,14 +1269,32 @@ async function handleHttpTrigger(req, runtime, ref, triggerName, triggerPath, pa
1192
1269
  const caller = await resolveCaller(req, {
1193
1270
  auth: ref.auth,
1194
1271
  credentialResolver: ref.credentialResolver,
1195
- oauthBearer: ref.oauthBearer,
1272
+ jwtBearer: ref.jwtBearer,
1196
1273
  env,
1197
1274
  waitUntil,
1198
1275
  });
1199
1276
  if (caller.kind === "invalid") {
1200
1277
  return Response.json({ ok: false, diagnostic: caller.diagnostic }, { status: caller.status });
1201
1278
  }
1202
- const body = await readBody(req);
1279
+ if (caller.context.auth?.credential === "session") {
1280
+ const rejected = rejectCrossOriginMutation(req);
1281
+ if (rejected)
1282
+ return rejected;
1283
+ }
1284
+ let body;
1285
+ try {
1286
+ body = await readBody(req);
1287
+ }
1288
+ catch {
1289
+ const diagnostic = runtimeDiagnostic({
1290
+ code: "INPUT_VALIDATION_FAILED",
1291
+ severity: "error",
1292
+ path: `${triggerPathPrefix}#/body`,
1293
+ expected: "valid JSON",
1294
+ message: "HTTP Trigger request body is not valid JSON.",
1295
+ });
1296
+ return Response.json({ ok: false, diagnostic: redactForWire(diagnostic) }, { status: 400 });
1297
+ }
1203
1298
  // Spread order matters: URL path params are authoritative for the
1204
1299
  // resource identifier (a `DELETE /entries/{id}` body MUST NOT spoof
1205
1300
  // `id`). Body fields fill in non-path inputs only.
@@ -1237,7 +1332,7 @@ mountPath) {
1237
1332
  const caller = await resolveCaller(req, {
1238
1333
  auth: ref.auth,
1239
1334
  credentialResolver: ref.credentialResolver,
1240
- oauthBearer: ref.oauthBearer,
1335
+ jwtBearer: ref.jwtBearer,
1241
1336
  env,
1242
1337
  waitUntil,
1243
1338
  });
@@ -1299,13 +1394,8 @@ async function readBody(req) {
1299
1394
  const ct = req.headers.get("content-type") ?? "";
1300
1395
  if (!ct.includes("json"))
1301
1396
  return {};
1302
- try {
1303
- const parsed = await req.json();
1304
- return parsed ?? {};
1305
- }
1306
- catch {
1307
- return {};
1308
- }
1397
+ const parsed = await req.json();
1398
+ return parsed ?? {};
1309
1399
  }
1310
1400
  function openApiToHono(path) {
1311
1401
  return path.replace(/\{([^}]+)\}/g, ":$1");
@@ -1366,18 +1456,22 @@ function adminUnauthenticated(c, path) {
1366
1456
  // instead of bouncing them back to /admin/sign-in (which the OAuth
1367
1457
  // re-auth then silently fast-forwards through, producing a visible
1368
1458
  // 5-step redirect chain that looks like an infinite loop).
1369
- function adminNotOwner(c, path) {
1459
+ function adminInsufficientRole(c, path, minimumRole) {
1460
+ const diagnostic = adminRoleDiagnostic(`${c.req.method} ${path}`, minimumRole, `This action requires the ${minimumRole} role.`);
1370
1461
  return Response.json({
1371
1462
  ok: false,
1372
- diagnostic: runtimeDiagnostic({
1373
- code: "AUTH_DENIED",
1374
- severity: "error",
1375
- path: `${c.req.method} ${path}`,
1376
- expected: "owner role for the signed-in user",
1377
- message: "Staff management is owner-only.",
1378
- }),
1463
+ diagnostic,
1379
1464
  }, { status: 403 });
1380
1465
  }
1466
+ function adminRoleDiagnostic(path, minimumRole, message) {
1467
+ return runtimeDiagnostic({
1468
+ code: "AUTH_DENIED",
1469
+ severity: "error",
1470
+ path,
1471
+ expected: `${minimumRole} role or higher for the signed-in user`,
1472
+ message,
1473
+ });
1474
+ }
1381
1475
  function adminNotStaff(c, path, login) {
1382
1476
  return Response.json({
1383
1477
  ok: false,