@aotter/mantle-cloudflare 0.1.0-alpha.7 → 0.1.0-alpha.8

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