@aotter/mantle-cloudflare 0.1.0-alpha.1 → 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 (45) hide show
  1. package/README.md +7 -1
  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/bindings/D1DatabaseDriver.js +21 -13
  7. package/dist/bindings/D1DatabaseDriver.js.map +1 -1
  8. package/dist/mount/bootRuntimeOnce.d.ts +1 -1
  9. package/dist/mount/bootRuntimeOnce.d.ts.map +1 -1
  10. package/dist/mount/bootRuntimeOnce.js +49 -4
  11. package/dist/mount/bootRuntimeOnce.js.map +1 -1
  12. package/dist/mount/cmsConfig.d.ts +1 -1
  13. package/dist/mount/cmsConfig.d.ts.map +1 -1
  14. package/dist/mount/mountMcp.d.ts +4 -4
  15. package/dist/mount/mountMcp.d.ts.map +1 -1
  16. package/dist/mount/mountMcp.js +34 -20
  17. package/dist/mount/mountMcp.js.map +1 -1
  18. package/dist/mount/mountServerEndpoints.d.ts +3 -4
  19. package/dist/mount/mountServerEndpoints.d.ts.map +1 -1
  20. package/dist/mount/mountServerEndpoints.js +216 -75
  21. package/dist/mount/mountServerEndpoints.js.map +1 -1
  22. package/dist/mount/resolveCaller.d.ts +2 -2
  23. package/dist/mount/resolveCaller.d.ts.map +1 -1
  24. package/dist/mount/resolveCaller.js +45 -14
  25. package/dist/mount/resolveCaller.js.map +1 -1
  26. package/dist/oauth/consentHtml.d.ts +1 -1
  27. package/dist/oauth/consentHtml.d.ts.map +1 -1
  28. package/dist/oauth/consentHtml.js +15 -13
  29. package/dist/oauth/consentHtml.js.map +1 -1
  30. package/dist/oauth/index.d.ts +1 -1
  31. package/dist/oauth/index.d.ts.map +1 -1
  32. package/dist/oauth/index.js.map +1 -1
  33. package/dist/oauth/mountOAuth.d.ts +0 -7
  34. package/dist/oauth/mountOAuth.d.ts.map +1 -1
  35. package/dist/oauth/mountOAuth.js +3 -7
  36. package/dist/oauth/mountOAuth.js.map +1 -1
  37. package/dist/oauth/oauthSingleton.d.ts +2 -3
  38. package/dist/oauth/oauthSingleton.d.ts.map +1 -1
  39. package/dist/oauth/oauthSingleton.js +6 -0
  40. package/dist/oauth/oauthSingleton.js.map +1 -1
  41. package/dist/worker/createMantleWorker.d.ts +4 -1
  42. package/dist/worker/createMantleWorker.d.ts.map +1 -1
  43. package/dist/worker/createMantleWorker.js +4 -1
  44. package/dist/worker/createMantleWorker.js.map +1 -1
  45. package/package.json +9 -9
@@ -1,17 +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
- import { resolveCaller, resolveUserRole } from "./resolveCaller.js";
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
7
  import { rejectCrossOriginMutation } from "../auth/rejectCrossOriginMutation.js";
8
8
  import { AOTTER_FAVICON_SVG } from "../assets/aotterFavicon.js";
9
9
  const [PAGE_PARAM, SHOW_PARAM] = VIEW_PARAMS_RESERVED;
10
10
  /** Mount HTTP Triggers + Views + the Better Auth admin surface.
11
- * HTTP Trigger bearer-token authentication is delegated to the OAuth
12
- * provider lib (via `createMcpApiHandler`) — if a Trigger needs
13
- * identity, route it under an MCP `apiHandler` instead of a Hono
14
- * catch-all. */
11
+ * HTTP Triggers resolve configured credentials and sessions through
12
+ * `resolveCaller`; the target Procedure's `requires.auth` owns
13
+ * authorization. */
15
14
  export function mountServerEndpoints(app, ref) {
16
15
  for (const t of ref.manifests) {
17
16
  if (t.kind !== "Trigger")
@@ -90,10 +89,10 @@ function mountAdminBetterAuth(app, ref, auth) {
90
89
  "/admin/c/:collection",
91
90
  "/admin/c/:collection/:id",
92
91
  "/admin/media",
93
- "/admin/approvals",
94
92
  "/admin/preferences",
95
93
  "/admin/settings",
96
94
  "/admin/staff",
95
+ "/admin/members",
97
96
  "/admin/ops",
98
97
  "/admin/views/:name",
99
98
  ]) {
@@ -147,19 +146,47 @@ function mountAdminBetterAuth(app, ref, auth) {
147
146
  return body(c, gate);
148
147
  });
149
148
  };
150
- // Staff management is owner-only — a step above `guarded` (any
151
- // staff). Role semantics follow StaffRoleHierarchy: only `owner`
152
- // "manages staff".
153
- const ownerGuarded = (method, path, body) => {
149
+ const roleGuarded = (method, path, minimumRole, body) => {
154
150
  guarded(method, path, (c, gate) => {
155
- if (gate.role !== "owner")
156
- return adminNotOwner(c, path);
151
+ if (!meetsRole(gate.role, minimumRole)) {
152
+ return adminInsufficientRole(c, path, minimumRole);
153
+ }
157
154
  return body(c, gate);
158
155
  });
159
156
  };
160
- guarded("get", "/admin/api/me", (_c, gate) => Response.json({ login: gate.login, role: gate.role, userId: gate.userId }));
161
- ownerGuarded("get", "/admin/api/staff", async () => Response.json({ users: await auth.listUsers() }));
162
- 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) => {
163
190
  const userId = c.req.param("id") ?? "";
164
191
  const body = (await c.req.raw.json().catch(() => ({})));
165
192
  const role = body.role === null ? null : typeof body.role === "string" ? body.role : undefined;
@@ -205,7 +232,7 @@ function mountAdminBetterAuth(app, ref, auth) {
205
232
  }
206
233
  return Response.json({ ok: true });
207
234
  });
208
- ownerGuarded("post", "/admin/api/staff/invitations", async (c) => {
235
+ roleGuarded("post", "/admin/api/staff/invitations", "owner", async (c, gate) => {
209
236
  const body = (await c.req.raw.json().catch(() => ({})));
210
237
  const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
211
238
  const role = typeof body.role === "string" ? body.role : "";
@@ -223,21 +250,23 @@ function mountAdminBetterAuth(app, ref, auth) {
223
250
  }
224
251
  const result = await auth.inviteUser(email, role);
225
252
  if (result.kind === "exists") {
226
- return Response.json({
227
- ok: false,
228
- userId: result.id,
229
- diagnostic: runtimeDiagnostic({
230
- code: "CONFLICT",
231
- severity: "error",
232
- path: "POST /admin/api/staff/invitations",
233
- expected: "an email without an existing user row",
234
- message: "That email already has an account — adjust its role in the staff list instead.",
235
- }),
236
- }, { 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);
237
266
  }
238
267
  return Response.json({ ok: true, userId: result.id });
239
268
  });
240
- ownerGuarded("delete", "/admin/api/staff/invitations/:id", async (c) => {
269
+ roleGuarded("delete", "/admin/api/staff/invitations/:id", "owner", async (c) => {
241
270
  const revoked = await auth.revokeInvite(c.req.param("id") ?? "");
242
271
  if (!revoked) {
243
272
  return Response.json({
@@ -268,8 +297,8 @@ function mountAdminBetterAuth(app, ref, auth) {
268
297
  return handleViewRequest(c.req.raw, runtime, viewName, ref, c.env, waitUntil, "/admin/api/views");
269
298
  });
270
299
  }
271
- guarded("get", "/admin/api/operations", () => Response.json({
272
- 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) => ({
273
302
  name: op.name,
274
303
  title: op.title,
275
304
  description: op.description,
@@ -316,19 +345,19 @@ function mountAdminBetterAuth(app, ref, auth) {
316
345
  });
317
346
  guarded("get", "/admin/api/site", async (c) => {
318
347
  const runtime = await ref.get();
319
- const site = await runtime.siteConfig.load();
320
- 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;
321
350
  return Response.json({
322
351
  ...site,
323
- publicUrl: site.origin || url.origin,
324
- mcpUrl: `${url.origin}/mcp/staff`,
352
+ publicUrl,
353
+ mcpUrl: `${publicUrl}/mcp/staff`,
325
354
  });
326
355
  });
327
- 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 () => {
328
357
  const runtime = await ref.get();
329
358
  return adminSiteSettings(await runtime.siteConfig.load());
330
359
  }));
331
- 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 () => {
332
361
  const runtime = await ref.get();
333
362
  const body = (await c.req.raw.json().catch(() => ({})));
334
363
  const site = await runtime.updateSiteSettings.execute({
@@ -358,6 +387,21 @@ function mountAdminBetterAuth(app, ref, auth) {
358
387
  const rawLimit = c.req.query("limit");
359
388
  const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : NaN;
360
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
+ }
361
405
  // Admin pagination needs the cursored shape — `executePage` returns
362
406
  // `{ rows, nextCursor? }`. `execute()` is the flat-array variant
363
407
  // for app code.
@@ -366,10 +410,57 @@ function mountAdminBetterAuth(app, ref, auth) {
366
410
  status: statusQuery && statusQuery !== "all" ? statusQuery : undefined,
367
411
  limit: Number.isFinite(parsedLimit) ? parsedLimit : 99,
368
412
  cursor: c.req.query("cursor") ?? undefined,
413
+ cursorDirection: c.req.query("cursor_direction") === "backward" ? "backward" : "forward",
369
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,
370
463
  });
371
- const items = result.rows.map((row) => adminListItem(row, schemasByName));
372
- return Response.json({ items, next_cursor: result.nextCursor ?? null });
373
464
  });
374
465
  guarded("get", "/admin/api/entries/export", async (c) => {
375
466
  const collection = c.req.query("collection");
@@ -427,6 +518,10 @@ function mountAdminBetterAuth(app, ref, auth) {
427
518
  message: "A non-empty `collection` is required.",
428
519
  }));
429
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
+ }
430
525
  const row = await runtime.createDraft.execute({
431
526
  collection: body.collection,
432
527
  data: objectField(body.data),
@@ -439,6 +534,13 @@ function mountAdminBetterAuth(app, ref, auth) {
439
534
  guarded("patch", "/admin/api/entries/:id", async (c, gate) => runMantleUseCase(`PATCH /admin/api/entries/${c.req.param("id")}`, async () => {
440
535
  const runtime = await ref.get();
441
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
+ }
442
544
  const body = (await c.req.raw.json().catch(() => ({})));
443
545
  const updated = await runtime.updateDraft.execute({
444
546
  id,
@@ -449,7 +551,7 @@ function mountAdminBetterAuth(app, ref, auth) {
449
551
  });
450
552
  return entryEditorPayload(runtime, updated, schemas);
451
553
  }));
452
- 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 () => {
453
555
  const runtime = await ref.get();
454
556
  const id = c.req.param("id");
455
557
  const body = await c.req.raw.json().catch(() => ({}));
@@ -460,7 +562,7 @@ function mountAdminBetterAuth(app, ref, auth) {
460
562
  });
461
563
  return entryEditorPayload(runtime, row, schemas);
462
564
  }));
463
- 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 () => {
464
566
  const runtime = await ref.get();
465
567
  const id = c.req.param("id");
466
568
  const body = await c.req.raw.json().catch(() => ({}));
@@ -471,7 +573,7 @@ function mountAdminBetterAuth(app, ref, auth) {
471
573
  });
472
574
  return entryEditorPayload(runtime, row, schemas);
473
575
  }));
474
- 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 () => {
475
577
  const runtime = await ref.get();
476
578
  const id = c.req.param("id");
477
579
  const body = await c.req.raw.json().catch(() => ({}));
@@ -486,7 +588,7 @@ function mountAdminBetterAuth(app, ref, auth) {
486
588
  // directly to R2 S3 (Worker bypassed) → POST /uploads/:groupId/commit.
487
589
  const MEDIA_UPLOADS_PATH = "/admin/api/media/uploads";
488
590
  const MEDIA_COMMIT_PATH = "/admin/api/media/uploads/:uploadGroupId/commit";
489
- guarded("post", MEDIA_UPLOADS_PATH, async (c) => {
591
+ roleGuarded("post", MEDIA_UPLOADS_PATH, "editor", async (c) => {
490
592
  const runtime = await ref.get();
491
593
  const media = runtime.media;
492
594
  if (!media)
@@ -548,7 +650,7 @@ function mountAdminBetterAuth(app, ref, auth) {
548
650
  caption: typeof body.caption === "string" ? body.caption : undefined,
549
651
  }));
550
652
  });
551
- guarded("post", MEDIA_COMMIT_PATH, async (c) => {
653
+ roleGuarded("post", MEDIA_COMMIT_PATH, "editor", async (c) => {
552
654
  const runtime = await ref.get();
553
655
  const media = runtime.media;
554
656
  if (!media)
@@ -568,7 +670,7 @@ function mountAdminBetterAuth(app, ref, auth) {
568
670
  // `mediaStorage` is bound (mirrors the upload handlers above).
569
671
  const MEDIA_LIST_PATH = "/admin/api/media";
570
672
  const MEDIA_ASSET_PATH = "/admin/api/media/:id";
571
- guarded("get", MEDIA_LIST_PATH, async (c) => {
673
+ roleGuarded("get", MEDIA_LIST_PATH, "editor", async (c) => {
572
674
  const runtime = await ref.get();
573
675
  const media = runtime.media;
574
676
  if (!media)
@@ -587,7 +689,7 @@ function mountAdminBetterAuth(app, ref, auth) {
587
689
  };
588
690
  });
589
691
  });
590
- guarded("get", MEDIA_ASSET_PATH, async (c) => {
692
+ roleGuarded("get", MEDIA_ASSET_PATH, "editor", async (c) => {
591
693
  const runtime = await ref.get();
592
694
  const media = runtime.media;
593
695
  if (!media)
@@ -595,7 +697,7 @@ function mountAdminBetterAuth(app, ref, auth) {
595
697
  const id = c.req.param("id");
596
698
  return runMantleUseCase(`GET ${MEDIA_ASSET_PATH}`, async () => adminMediaItem(await media.getAsset.execute(id)));
597
699
  });
598
- guarded("patch", MEDIA_ASSET_PATH, async (c) => {
700
+ roleGuarded("patch", MEDIA_ASSET_PATH, "editor", async (c) => {
599
701
  const runtime = await ref.get();
600
702
  const media = runtime.media;
601
703
  if (!media)
@@ -621,7 +723,7 @@ function mountAdminBetterAuth(app, ref, auth) {
621
723
  caption: typeof body.caption === "string" ? body.caption : undefined,
622
724
  })));
623
725
  });
624
- guarded("delete", MEDIA_ASSET_PATH, async (c) => {
726
+ roleGuarded("delete", MEDIA_ASSET_PATH, "editor", async (c) => {
625
727
  const runtime = await ref.get();
626
728
  const media = runtime.media;
627
729
  if (!media)
@@ -828,7 +930,7 @@ function titleFieldKey(data, schema) {
828
930
  * next to the system column (#443). NAME-based only, on purpose: no
829
931
  * fuzzy/semantic matching, just these two well-known reserved names. */
830
932
  const DATA_PREVIEW_SYSTEM_COLUMN_NAMES = new Set(["updatedAt", "createdAt"]);
831
- /** Operational (`lifecycle: none`) collections have no title/status
933
+ /** Operational collections have no title/status
832
934
  * workflow worth a dedicated column — instead the admin list shows up
833
935
  * to 3 raw data columns. Mirrors the client-side column-picking rule
834
936
  * in `collection-view.tsx`: first 3 `required` properties, skipping
@@ -836,7 +938,7 @@ const DATA_PREVIEW_SYSTEM_COLUMN_NAMES = new Set(["updatedAt", "createdAt"]);
836
938
  * above (#443). Kept small on the wire on purpose — this is a
837
939
  * preview, not the full entry. */
838
940
  function adminDataPreview(data, manifest) {
839
- if (!manifest || manifest.spec.lifecycle !== "none")
941
+ if (!manifest || manifest.spec.lifecycle !== "operational")
840
942
  return undefined;
841
943
  const schema = manifest.spec.schema;
842
944
  // Schema-stable skip (not per-row): rows with a blank title field
@@ -875,7 +977,7 @@ function isStringTypedSchema(schema) {
875
977
  const types = Array.isArray(rawType) ? rawType : rawType ? [rawType] : [];
876
978
  return types.includes("string");
877
979
  }
878
- function adminListItem(row, schemasByName) {
980
+ function adminListItem(row, schemasByName, translationLocales = []) {
879
981
  const manifest = schemasByName.get(row.collection);
880
982
  return {
881
983
  id: row.id,
@@ -885,6 +987,7 @@ function adminListItem(row, schemasByName) {
885
987
  version: row.version,
886
988
  title: adminEntryTitle(row.data, manifest?.spec.schema),
887
989
  updated_at: row.updatedAt,
990
+ translation_locales: translationLocales,
888
991
  data_preview: adminDataPreview(row.data, manifest),
889
992
  };
890
993
  }
@@ -938,6 +1041,7 @@ async function entryEditorPayload(runtime, row, schemas) {
938
1041
  return {
939
1042
  collection: adminEditorCollection(schema, schemas),
940
1043
  entry: adminEditorEntry(row),
1044
+ parentEntryId: await parentEntryId(runtime, schema, row, schemas),
941
1045
  related,
942
1046
  };
943
1047
  }
@@ -946,7 +1050,7 @@ function adminEditorCollection(schema, schemas) {
946
1050
  name: schema.metadata.name,
947
1051
  title: schema.spec.title,
948
1052
  description: schema.spec.description ?? null,
949
- lifecycle: schema.spec.lifecycle ?? "simple",
1053
+ lifecycle: schema.spec.lifecycle ?? "publishing",
950
1054
  parent: collectionParentFor(schema, schemas),
951
1055
  hasTranslations: schemas.some((candidate) => candidate.spec.translates?.parent === schema.metadata.name),
952
1056
  localized: schema.spec.localized ?? Boolean(schema.spec.translates),
@@ -954,6 +1058,8 @@ function adminEditorCollection(schema, schemas) {
954
1058
  schema: schema.spec.schema,
955
1059
  uiSchema: schema.spec.uiSchema ?? null,
956
1060
  mediaFields: mediaFieldsForCollection(schema, schemas),
1061
+ sortableFields: schemaSortableFields(schema),
1062
+ filter: checkSchemaListFilter(schema).filter,
957
1063
  };
958
1064
  }
959
1065
  function adminEditorEntry(row) {
@@ -974,7 +1080,9 @@ async function relatedEntrySections(runtime, parentSchema, parentRow, schemas) {
974
1080
  const childSchema = schemas.find((schema) => schema.metadata.name === relationship.collection);
975
1081
  if (!childSchema)
976
1082
  continue;
977
- 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);
978
1086
  sections.push({
979
1087
  collection: adminEditorCollection(childSchema, schemas),
980
1088
  relationship: {
@@ -994,7 +1102,7 @@ function discoverChildRelationships(parentSchema, parentRow, schemas) {
994
1102
  const seen = new Set();
995
1103
  const add = (childSchema, kind, parentField, childField, rawParentValue) => {
996
1104
  const parentValue = primitiveJoinValue(rawParentValue);
997
- if (parentValue == null)
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,6 +1116,10 @@ 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;
@@ -1051,6 +1163,17 @@ function collectionParentFor(childSchema, schemas) {
1051
1163
  }
1052
1164
  return null;
1053
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
+ }
1054
1177
  function primitiveJoinValue(value) {
1055
1178
  if (typeof value === "string" && value !== "")
1056
1179
  return value;
@@ -1060,6 +1183,9 @@ function primitiveJoinValue(value) {
1060
1183
  return value;
1061
1184
  return null;
1062
1185
  }
1186
+ function joinValueKey(value) {
1187
+ return `${typeof value}:${String(value)}`;
1188
+ }
1063
1189
  async function entriesByDataValue(runtime, collection, field, value) {
1064
1190
  return runtime.entryReader.findManyByDataField({
1065
1191
  collection,
@@ -1070,7 +1196,9 @@ async function entriesByDataValue(runtime, collection, field, value) {
1070
1196
  }
1071
1197
  function adminSiteSettings(site) {
1072
1198
  return {
1073
- ...site,
1199
+ brand: site.brand,
1200
+ title: site.title,
1201
+ description: site.description,
1074
1202
  ga4MeasurementId: site.ga4MeasurementId ?? "",
1075
1203
  facebookPixelId: site.facebookPixelId ?? "",
1076
1204
  };
@@ -1113,7 +1241,7 @@ async function readStaffGate(c, auth) {
1113
1241
  const session = await auth.getSession(c.req.raw);
1114
1242
  if (!session)
1115
1243
  return { kind: "unauth" };
1116
- const role = await resolveUserRole(auth, session.user.id, session.user.role);
1244
+ const role = await auth.getUserRole(session.user.id);
1117
1245
  const login = session.user.githubLogin ?? null;
1118
1246
  if (!role || !STAFF_ROLE_SET.has(role)) {
1119
1247
  return { kind: "forbidden", login };
@@ -1122,6 +1250,7 @@ async function readStaffGate(c, auth) {
1122
1250
  kind: "ok",
1123
1251
  userId: session.user.id,
1124
1252
  login,
1253
+ image: session.user.image ?? null,
1125
1254
  role: role,
1126
1255
  sessionId: session.session.id,
1127
1256
  };
@@ -1140,7 +1269,7 @@ async function handleHttpTrigger(req, runtime, ref, triggerName, triggerPath, pa
1140
1269
  const caller = await resolveCaller(req, {
1141
1270
  auth: ref.auth,
1142
1271
  credentialResolver: ref.credentialResolver,
1143
- oauthBearer: ref.oauthBearer,
1272
+ jwtBearer: ref.jwtBearer,
1144
1273
  env,
1145
1274
  waitUntil,
1146
1275
  });
@@ -1152,7 +1281,20 @@ async function handleHttpTrigger(req, runtime, ref, triggerName, triggerPath, pa
1152
1281
  if (rejected)
1153
1282
  return rejected;
1154
1283
  }
1155
- const body = await readBody(req);
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
+ }
1156
1298
  // Spread order matters: URL path params are authoritative for the
1157
1299
  // resource identifier (a `DELETE /entries/{id}` body MUST NOT spoof
1158
1300
  // `id`). Body fields fill in non-path inputs only.
@@ -1190,7 +1332,7 @@ mountPath) {
1190
1332
  const caller = await resolveCaller(req, {
1191
1333
  auth: ref.auth,
1192
1334
  credentialResolver: ref.credentialResolver,
1193
- oauthBearer: ref.oauthBearer,
1335
+ jwtBearer: ref.jwtBearer,
1194
1336
  env,
1195
1337
  waitUntil,
1196
1338
  });
@@ -1252,13 +1394,8 @@ async function readBody(req) {
1252
1394
  const ct = req.headers.get("content-type") ?? "";
1253
1395
  if (!ct.includes("json"))
1254
1396
  return {};
1255
- try {
1256
- const parsed = await req.json();
1257
- return parsed ?? {};
1258
- }
1259
- catch {
1260
- return {};
1261
- }
1397
+ const parsed = await req.json();
1398
+ return parsed ?? {};
1262
1399
  }
1263
1400
  function openApiToHono(path) {
1264
1401
  return path.replace(/\{([^}]+)\}/g, ":$1");
@@ -1319,18 +1456,22 @@ function adminUnauthenticated(c, path) {
1319
1456
  // instead of bouncing them back to /admin/sign-in (which the OAuth
1320
1457
  // re-auth then silently fast-forwards through, producing a visible
1321
1458
  // 5-step redirect chain that looks like an infinite loop).
1322
- 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.`);
1323
1461
  return Response.json({
1324
1462
  ok: false,
1325
- diagnostic: runtimeDiagnostic({
1326
- code: "AUTH_DENIED",
1327
- severity: "error",
1328
- path: `${c.req.method} ${path}`,
1329
- expected: "owner role for the signed-in user",
1330
- message: "Staff management is owner-only.",
1331
- }),
1463
+ diagnostic,
1332
1464
  }, { status: 403 });
1333
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
+ }
1334
1475
  function adminNotStaff(c, path, login) {
1335
1476
  return Response.json({
1336
1477
  ok: false,