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