@aotter/mantle-cloudflare 0.1.0-alpha.1 → 0.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/auth/createAuth.d.ts +22 -10
- package/dist/auth/createAuth.d.ts.map +1 -1
- package/dist/auth/createAuth.js +69 -1
- package/dist/auth/createAuth.js.map +1 -1
- package/dist/bindings/D1DatabaseDriver.js +21 -13
- package/dist/bindings/D1DatabaseDriver.js.map +1 -1
- package/dist/mount/bootRuntimeOnce.d.ts +1 -1
- package/dist/mount/bootRuntimeOnce.d.ts.map +1 -1
- package/dist/mount/bootRuntimeOnce.js +49 -4
- package/dist/mount/bootRuntimeOnce.js.map +1 -1
- package/dist/mount/cmsConfig.d.ts +1 -1
- package/dist/mount/cmsConfig.d.ts.map +1 -1
- package/dist/mount/mountMcp.d.ts +4 -4
- package/dist/mount/mountMcp.d.ts.map +1 -1
- package/dist/mount/mountMcp.js +34 -20
- package/dist/mount/mountMcp.js.map +1 -1
- package/dist/mount/mountServerEndpoints.d.ts +3 -4
- package/dist/mount/mountServerEndpoints.d.ts.map +1 -1
- package/dist/mount/mountServerEndpoints.js +216 -75
- package/dist/mount/mountServerEndpoints.js.map +1 -1
- package/dist/mount/resolveCaller.d.ts +2 -2
- package/dist/mount/resolveCaller.d.ts.map +1 -1
- package/dist/mount/resolveCaller.js +45 -14
- package/dist/mount/resolveCaller.js.map +1 -1
- package/dist/oauth/consentHtml.d.ts +1 -1
- package/dist/oauth/consentHtml.d.ts.map +1 -1
- package/dist/oauth/consentHtml.js +15 -13
- package/dist/oauth/consentHtml.js.map +1 -1
- package/dist/oauth/index.d.ts +1 -1
- package/dist/oauth/index.d.ts.map +1 -1
- package/dist/oauth/index.js.map +1 -1
- package/dist/oauth/mountOAuth.d.ts +0 -7
- package/dist/oauth/mountOAuth.d.ts.map +1 -1
- package/dist/oauth/mountOAuth.js +3 -7
- package/dist/oauth/mountOAuth.js.map +1 -1
- package/dist/oauth/oauthSingleton.d.ts +2 -3
- package/dist/oauth/oauthSingleton.d.ts.map +1 -1
- package/dist/oauth/oauthSingleton.js +6 -0
- package/dist/oauth/oauthSingleton.js.map +1 -1
- package/dist/worker/createMantleWorker.d.ts +4 -1
- package/dist/worker/createMantleWorker.d.ts.map +1 -1
- package/dist/worker/createMantleWorker.js +4 -1
- package/dist/worker/createMantleWorker.js.map +1 -1
- package/package.json +9 -9
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
import { DiagnosticError, HTTP_STATUS_BY_CODE, MANTLE_REF_KEYWORD, MCP_HINT_KEYWORD, VIEW_PARAMS_RESERVED, isMediaMcpHint, redactForWire, runtimeDiagnostic, } from "@aotter/mantle-spec";
|
|
1
|
+
import { DiagnosticError, HTTP_STATUS_BY_CODE, MANTLE_REF_KEYWORD, MCP_HINT_KEYWORD, VIEW_PARAMS_RESERVED, isMediaMcpHint, meetsRole, redactForWire, runtimeDiagnostic, checkSchemaListFilter, schemaSortableFields, } from "@aotter/mantle-spec";
|
|
2
2
|
import { ViewParamCoercionError, coerceViewParams, compilePathMatcher, evaluateAuthAll, } from "@aotter/mantle-runtime";
|
|
3
3
|
import { indexHtml } from "@aotter/mantle-admin-ui";
|
|
4
|
-
import { resolveCaller
|
|
4
|
+
import { resolveCaller } from "./resolveCaller.js";
|
|
5
5
|
import { runMantleUseCase } from "./runMantleUseCase.js";
|
|
6
|
-
import { STAFF_ROLE_SET } from "../auth/createAuth.js";
|
|
6
|
+
import { decodeMemberCursor, STAFF_ROLE_SET, } from "../auth/createAuth.js";
|
|
7
7
|
import { rejectCrossOriginMutation } from "../auth/rejectCrossOriginMutation.js";
|
|
8
8
|
import { AOTTER_FAVICON_SVG } from "../assets/aotterFavicon.js";
|
|
9
9
|
const [PAGE_PARAM, SHOW_PARAM] = VIEW_PARAMS_RESERVED;
|
|
10
10
|
/** Mount HTTP Triggers + Views + the Better Auth admin surface.
|
|
11
|
-
* HTTP
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* catch-all. */
|
|
11
|
+
* HTTP Triggers resolve configured credentials and sessions through
|
|
12
|
+
* `resolveCaller`; the target Procedure's `requires.auth` owns
|
|
13
|
+
* authorization. */
|
|
15
14
|
export function mountServerEndpoints(app, ref) {
|
|
16
15
|
for (const t of ref.manifests) {
|
|
17
16
|
if (t.kind !== "Trigger")
|
|
@@ -90,10 +89,10 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
90
89
|
"/admin/c/:collection",
|
|
91
90
|
"/admin/c/:collection/:id",
|
|
92
91
|
"/admin/media",
|
|
93
|
-
"/admin/approvals",
|
|
94
92
|
"/admin/preferences",
|
|
95
93
|
"/admin/settings",
|
|
96
94
|
"/admin/staff",
|
|
95
|
+
"/admin/members",
|
|
97
96
|
"/admin/ops",
|
|
98
97
|
"/admin/views/:name",
|
|
99
98
|
]) {
|
|
@@ -147,19 +146,47 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
147
146
|
return body(c, gate);
|
|
148
147
|
});
|
|
149
148
|
};
|
|
150
|
-
|
|
151
|
-
// staff). Role semantics follow StaffRoleHierarchy: only `owner`
|
|
152
|
-
// "manages staff".
|
|
153
|
-
const ownerGuarded = (method, path, body) => {
|
|
149
|
+
const roleGuarded = (method, path, minimumRole, body) => {
|
|
154
150
|
guarded(method, path, (c, gate) => {
|
|
155
|
-
if (gate.role
|
|
156
|
-
return
|
|
151
|
+
if (!meetsRole(gate.role, minimumRole)) {
|
|
152
|
+
return adminInsufficientRole(c, path, minimumRole);
|
|
153
|
+
}
|
|
157
154
|
return body(c, gate);
|
|
158
155
|
});
|
|
159
156
|
};
|
|
160
|
-
guarded("get", "/admin/api/me", (_c, gate) => Response.json({ login: gate.login, role: gate.role, userId: gate.userId }));
|
|
161
|
-
|
|
162
|
-
|
|
157
|
+
guarded("get", "/admin/api/me", (_c, gate) => Response.json({ login: gate.login, role: gate.role, userId: gate.userId, image: gate.image }));
|
|
158
|
+
roleGuarded("get", "/admin/api/staff", "owner", async () => Response.json({ users: await auth.listUsers() }));
|
|
159
|
+
roleGuarded("get", "/admin/api/members", "editor", async (c) => {
|
|
160
|
+
const rawLimit = c.req.query("limit");
|
|
161
|
+
const limit = rawLimit === undefined ? 50 : Number(rawLimit);
|
|
162
|
+
const cursor = c.req.query("cursor") || undefined;
|
|
163
|
+
const search = c.req.query("search")?.trim() || undefined;
|
|
164
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100 ||
|
|
165
|
+
(cursor && !decodeMemberCursor(cursor)) || (search?.length ?? 0) > 200) {
|
|
166
|
+
return Response.json({
|
|
167
|
+
ok: false,
|
|
168
|
+
diagnostic: runtimeDiagnostic({
|
|
169
|
+
code: "INPUT_VALIDATION_FAILED",
|
|
170
|
+
severity: "error",
|
|
171
|
+
path: "GET /admin/api/members",
|
|
172
|
+
expected: "limit 1..100, a valid cursor, and search up to 200 characters",
|
|
173
|
+
message: "Member list parameters are invalid.",
|
|
174
|
+
}),
|
|
175
|
+
}, { status: 400 });
|
|
176
|
+
}
|
|
177
|
+
const result = await auth.listMembers({
|
|
178
|
+
limit,
|
|
179
|
+
search,
|
|
180
|
+
cursor,
|
|
181
|
+
cursorDirection: c.req.query("cursor_direction") === "backward" ? "backward" : "forward",
|
|
182
|
+
});
|
|
183
|
+
return Response.json({
|
|
184
|
+
items: result.items,
|
|
185
|
+
previous_cursor: result.previousCursor,
|
|
186
|
+
next_cursor: result.nextCursor,
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
roleGuarded("patch", "/admin/api/staff/:id/role", "owner", async (c, gate) => {
|
|
163
190
|
const userId = c.req.param("id") ?? "";
|
|
164
191
|
const body = (await c.req.raw.json().catch(() => ({})));
|
|
165
192
|
const role = body.role === null ? null : typeof body.role === "string" ? body.role : undefined;
|
|
@@ -205,7 +232,7 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
205
232
|
}
|
|
206
233
|
return Response.json({ ok: true });
|
|
207
234
|
});
|
|
208
|
-
|
|
235
|
+
roleGuarded("post", "/admin/api/staff/invitations", "owner", async (c, gate) => {
|
|
209
236
|
const body = (await c.req.raw.json().catch(() => ({})));
|
|
210
237
|
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
|
|
211
238
|
const role = typeof body.role === "string" ? body.role : "";
|
|
@@ -223,21 +250,23 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
223
250
|
}
|
|
224
251
|
const result = await auth.inviteUser(email, role);
|
|
225
252
|
if (result.kind === "exists") {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
253
|
+
if (result.id === gate.userId) {
|
|
254
|
+
return Response.json({
|
|
255
|
+
ok: false,
|
|
256
|
+
diagnostic: runtimeDiagnostic({
|
|
257
|
+
code: "AUTH_DENIED",
|
|
258
|
+
severity: "error",
|
|
259
|
+
path: "POST /admin/api/staff/invitations",
|
|
260
|
+
expected: "an email other than the caller's",
|
|
261
|
+
message: "You cannot change your own role.",
|
|
262
|
+
}),
|
|
263
|
+
}, { status: 403 });
|
|
264
|
+
}
|
|
265
|
+
await auth.setUserRole(result.id, role);
|
|
237
266
|
}
|
|
238
267
|
return Response.json({ ok: true, userId: result.id });
|
|
239
268
|
});
|
|
240
|
-
|
|
269
|
+
roleGuarded("delete", "/admin/api/staff/invitations/:id", "owner", async (c) => {
|
|
241
270
|
const revoked = await auth.revokeInvite(c.req.param("id") ?? "");
|
|
242
271
|
if (!revoked) {
|
|
243
272
|
return Response.json({
|
|
@@ -268,8 +297,8 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
268
297
|
return handleViewRequest(c.req.raw, runtime, viewName, ref, c.env, waitUntil, "/admin/api/views");
|
|
269
298
|
});
|
|
270
299
|
}
|
|
271
|
-
guarded("get", "/admin/api/operations", () => Response.json({
|
|
272
|
-
operations: operations.map((op) => ({
|
|
300
|
+
guarded("get", "/admin/api/operations", (c, gate) => Response.json({
|
|
301
|
+
operations: operations.filter((op) => evaluateAuthAll(op.procedure.spec.requires, adminHandlerContext(c, gate), `GET /admin/api/operations/${op.name}`, "runtime") === null).map((op) => ({
|
|
273
302
|
name: op.name,
|
|
274
303
|
title: op.title,
|
|
275
304
|
description: op.description,
|
|
@@ -316,19 +345,19 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
316
345
|
});
|
|
317
346
|
guarded("get", "/admin/api/site", async (c) => {
|
|
318
347
|
const runtime = await ref.get();
|
|
319
|
-
const site = await runtime.siteConfig.load();
|
|
320
|
-
const
|
|
348
|
+
const { origin, ...site } = await runtime.siteConfig.load();
|
|
349
|
+
const publicUrl = origin || new URL(c.req.url).origin;
|
|
321
350
|
return Response.json({
|
|
322
351
|
...site,
|
|
323
|
-
publicUrl
|
|
324
|
-
mcpUrl: `${
|
|
352
|
+
publicUrl,
|
|
353
|
+
mcpUrl: `${publicUrl}/mcp/staff`,
|
|
325
354
|
});
|
|
326
355
|
});
|
|
327
|
-
|
|
356
|
+
roleGuarded("get", "/admin/api/site-settings", "owner", async () => runMantleUseCase("GET /admin/api/site-settings", async () => {
|
|
328
357
|
const runtime = await ref.get();
|
|
329
358
|
return adminSiteSettings(await runtime.siteConfig.load());
|
|
330
359
|
}));
|
|
331
|
-
|
|
360
|
+
roleGuarded("patch", "/admin/api/site-settings", "owner", async (c) => runMantleUseCase("PATCH /admin/api/site-settings", async () => {
|
|
332
361
|
const runtime = await ref.get();
|
|
333
362
|
const body = (await c.req.raw.json().catch(() => ({})));
|
|
334
363
|
const site = await runtime.updateSiteSettings.execute({
|
|
@@ -358,6 +387,21 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
358
387
|
const rawLimit = c.req.query("limit");
|
|
359
388
|
const parsedLimit = rawLimit ? Number.parseInt(rawLimit, 10) : NaN;
|
|
360
389
|
const statusQuery = c.req.query("status");
|
|
390
|
+
const sortDirection = c.req.query("direction") === "asc" ? "asc" : "desc";
|
|
391
|
+
const filterField = c.req.query("filter_field");
|
|
392
|
+
const filterValue = c.req.query("filter_value");
|
|
393
|
+
if (Boolean(filterField) !== Boolean(filterValue)) {
|
|
394
|
+
return Response.json({
|
|
395
|
+
ok: false,
|
|
396
|
+
diagnostic: runtimeDiagnostic({
|
|
397
|
+
code: "INPUT_VALIDATION_FAILED",
|
|
398
|
+
severity: "error",
|
|
399
|
+
path: "GET /admin/api/entries",
|
|
400
|
+
expected: "filter_field and filter_value together",
|
|
401
|
+
message: "List filters require both `filter_field` and `filter_value`.",
|
|
402
|
+
}),
|
|
403
|
+
}, { status: 400 });
|
|
404
|
+
}
|
|
361
405
|
// Admin pagination needs the cursored shape — `executePage` returns
|
|
362
406
|
// `{ rows, nextCursor? }`. `execute()` is the flat-array variant
|
|
363
407
|
// for app code.
|
|
@@ -366,10 +410,57 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
366
410
|
status: statusQuery && statusQuery !== "all" ? statusQuery : undefined,
|
|
367
411
|
limit: Number.isFinite(parsedLimit) ? parsedLimit : 99,
|
|
368
412
|
cursor: c.req.query("cursor") ?? undefined,
|
|
413
|
+
cursorDirection: c.req.query("cursor_direction") === "backward" ? "backward" : "forward",
|
|
369
414
|
search: c.req.query("search") || undefined,
|
|
415
|
+
filter: filterField && filterValue
|
|
416
|
+
? { field: filterField, value: filterValue }
|
|
417
|
+
: undefined,
|
|
418
|
+
sort: {
|
|
419
|
+
field: c.req.query("sort") || "updatedAt",
|
|
420
|
+
direction: sortDirection,
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
const translationLocales = new Map();
|
|
424
|
+
const translationSchemas = schemas.filter((schema) => schema.spec.translates?.parent === collection);
|
|
425
|
+
for (const schema of translationSchemas) {
|
|
426
|
+
const field = schema.spec.translates.on;
|
|
427
|
+
const parentValues = new Map();
|
|
428
|
+
for (const row of result.rows) {
|
|
429
|
+
const value = primitiveJoinValue(row.data[field]);
|
|
430
|
+
if (value !== null)
|
|
431
|
+
parentValues.set(joinValueKey(value), value);
|
|
432
|
+
}
|
|
433
|
+
const translations = await runtime.entryReader.readByDataFieldIn({
|
|
434
|
+
collection: schema.metadata.name,
|
|
435
|
+
field,
|
|
436
|
+
values: [...parentValues.values()],
|
|
437
|
+
});
|
|
438
|
+
const localesByValue = new Map();
|
|
439
|
+
for (const entry of translations) {
|
|
440
|
+
const value = primitiveJoinValue(entry.data[field]);
|
|
441
|
+
if (value === null || !entry.locale)
|
|
442
|
+
continue;
|
|
443
|
+
const key = joinValueKey(value);
|
|
444
|
+
const locales = localesByValue.get(key) ?? new Set();
|
|
445
|
+
locales.add(entry.locale);
|
|
446
|
+
localesByValue.set(key, locales);
|
|
447
|
+
}
|
|
448
|
+
for (const row of result.rows) {
|
|
449
|
+
const value = primitiveJoinValue(row.data[field]);
|
|
450
|
+
if (value === null)
|
|
451
|
+
continue;
|
|
452
|
+
const locales = translationLocales.get(row.id) ?? new Set();
|
|
453
|
+
for (const locale of localesByValue.get(joinValueKey(value)) ?? [])
|
|
454
|
+
locales.add(locale);
|
|
455
|
+
translationLocales.set(row.id, locales);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const items = result.rows.map((row) => adminListItem(row, schemasByName, [...(translationLocales.get(row.id) ?? [])]));
|
|
459
|
+
return Response.json({
|
|
460
|
+
items,
|
|
461
|
+
previous_cursor: result.previousCursor ?? null,
|
|
462
|
+
next_cursor: result.nextCursor ?? null,
|
|
370
463
|
});
|
|
371
|
-
const items = result.rows.map((row) => adminListItem(row, schemasByName));
|
|
372
|
-
return Response.json({ items, next_cursor: result.nextCursor ?? null });
|
|
373
464
|
});
|
|
374
465
|
guarded("get", "/admin/api/entries/export", async (c) => {
|
|
375
466
|
const collection = c.req.query("collection");
|
|
@@ -427,6 +518,10 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
427
518
|
message: "A non-empty `collection` is required.",
|
|
428
519
|
}));
|
|
429
520
|
}
|
|
521
|
+
if (gate.role === "contributor" &&
|
|
522
|
+
(schemasByName.get(body.collection)?.spec.lifecycle ?? "publishing") === "operational") {
|
|
523
|
+
throw new DiagnosticError(adminRoleDiagnostic("POST /admin/api/entries", "editor", "Contributors can create drafts, not operational records."));
|
|
524
|
+
}
|
|
430
525
|
const row = await runtime.createDraft.execute({
|
|
431
526
|
collection: body.collection,
|
|
432
527
|
data: objectField(body.data),
|
|
@@ -439,6 +534,13 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
439
534
|
guarded("patch", "/admin/api/entries/:id", async (c, gate) => runMantleUseCase(`PATCH /admin/api/entries/${c.req.param("id")}`, async () => {
|
|
440
535
|
const runtime = await ref.get();
|
|
441
536
|
const id = c.req.param("id");
|
|
537
|
+
if (gate.role === "contributor") {
|
|
538
|
+
const current = await runtime.getEntry.execute({ id });
|
|
539
|
+
const lifecycle = schemasByName.get(current.collection)?.spec.lifecycle ?? "publishing";
|
|
540
|
+
if (lifecycle === "operational" || current.status !== "draft") {
|
|
541
|
+
throw new DiagnosticError(adminRoleDiagnostic(`PATCH /admin/api/entries/${id}`, "editor", "Contributors can edit drafts only."));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
442
544
|
const body = (await c.req.raw.json().catch(() => ({})));
|
|
443
545
|
const updated = await runtime.updateDraft.execute({
|
|
444
546
|
id,
|
|
@@ -449,7 +551,7 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
449
551
|
});
|
|
450
552
|
return entryEditorPayload(runtime, updated, schemas);
|
|
451
553
|
}));
|
|
452
|
-
|
|
554
|
+
roleGuarded("post", "/admin/api/entries/:id/publish", "editor", async (c, gate) => runMantleUseCase(`POST /admin/api/entries/${c.req.param("id")}/publish`, async () => {
|
|
453
555
|
const runtime = await ref.get();
|
|
454
556
|
const id = c.req.param("id");
|
|
455
557
|
const body = await c.req.raw.json().catch(() => ({}));
|
|
@@ -460,7 +562,7 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
460
562
|
});
|
|
461
563
|
return entryEditorPayload(runtime, row, schemas);
|
|
462
564
|
}));
|
|
463
|
-
|
|
565
|
+
roleGuarded("post", "/admin/api/entries/:id/unpublish", "editor", async (c, gate) => runMantleUseCase(`POST /admin/api/entries/${c.req.param("id")}/unpublish`, async () => {
|
|
464
566
|
const runtime = await ref.get();
|
|
465
567
|
const id = c.req.param("id");
|
|
466
568
|
const body = await c.req.raw.json().catch(() => ({}));
|
|
@@ -471,7 +573,7 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
471
573
|
});
|
|
472
574
|
return entryEditorPayload(runtime, row, schemas);
|
|
473
575
|
}));
|
|
474
|
-
|
|
576
|
+
roleGuarded("delete", "/admin/api/entries/:id", "editor", async (c, gate) => runMantleUseCase(`DELETE /admin/api/entries/${c.req.param("id")}`, async () => {
|
|
475
577
|
const runtime = await ref.get();
|
|
476
578
|
const id = c.req.param("id");
|
|
477
579
|
const body = await c.req.raw.json().catch(() => ({}));
|
|
@@ -486,7 +588,7 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
486
588
|
// directly to R2 S3 (Worker bypassed) → POST /uploads/:groupId/commit.
|
|
487
589
|
const MEDIA_UPLOADS_PATH = "/admin/api/media/uploads";
|
|
488
590
|
const MEDIA_COMMIT_PATH = "/admin/api/media/uploads/:uploadGroupId/commit";
|
|
489
|
-
|
|
591
|
+
roleGuarded("post", MEDIA_UPLOADS_PATH, "editor", async (c) => {
|
|
490
592
|
const runtime = await ref.get();
|
|
491
593
|
const media = runtime.media;
|
|
492
594
|
if (!media)
|
|
@@ -548,7 +650,7 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
548
650
|
caption: typeof body.caption === "string" ? body.caption : undefined,
|
|
549
651
|
}));
|
|
550
652
|
});
|
|
551
|
-
|
|
653
|
+
roleGuarded("post", MEDIA_COMMIT_PATH, "editor", async (c) => {
|
|
552
654
|
const runtime = await ref.get();
|
|
553
655
|
const media = runtime.media;
|
|
554
656
|
if (!media)
|
|
@@ -568,7 +670,7 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
568
670
|
// `mediaStorage` is bound (mirrors the upload handlers above).
|
|
569
671
|
const MEDIA_LIST_PATH = "/admin/api/media";
|
|
570
672
|
const MEDIA_ASSET_PATH = "/admin/api/media/:id";
|
|
571
|
-
|
|
673
|
+
roleGuarded("get", MEDIA_LIST_PATH, "editor", async (c) => {
|
|
572
674
|
const runtime = await ref.get();
|
|
573
675
|
const media = runtime.media;
|
|
574
676
|
if (!media)
|
|
@@ -587,7 +689,7 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
587
689
|
};
|
|
588
690
|
});
|
|
589
691
|
});
|
|
590
|
-
|
|
692
|
+
roleGuarded("get", MEDIA_ASSET_PATH, "editor", async (c) => {
|
|
591
693
|
const runtime = await ref.get();
|
|
592
694
|
const media = runtime.media;
|
|
593
695
|
if (!media)
|
|
@@ -595,7 +697,7 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
595
697
|
const id = c.req.param("id");
|
|
596
698
|
return runMantleUseCase(`GET ${MEDIA_ASSET_PATH}`, async () => adminMediaItem(await media.getAsset.execute(id)));
|
|
597
699
|
});
|
|
598
|
-
|
|
700
|
+
roleGuarded("patch", MEDIA_ASSET_PATH, "editor", async (c) => {
|
|
599
701
|
const runtime = await ref.get();
|
|
600
702
|
const media = runtime.media;
|
|
601
703
|
if (!media)
|
|
@@ -621,7 +723,7 @@ function mountAdminBetterAuth(app, ref, auth) {
|
|
|
621
723
|
caption: typeof body.caption === "string" ? body.caption : undefined,
|
|
622
724
|
})));
|
|
623
725
|
});
|
|
624
|
-
|
|
726
|
+
roleGuarded("delete", MEDIA_ASSET_PATH, "editor", async (c) => {
|
|
625
727
|
const runtime = await ref.get();
|
|
626
728
|
const media = runtime.media;
|
|
627
729
|
if (!media)
|
|
@@ -828,7 +930,7 @@ function titleFieldKey(data, schema) {
|
|
|
828
930
|
* next to the system column (#443). NAME-based only, on purpose: no
|
|
829
931
|
* fuzzy/semantic matching, just these two well-known reserved names. */
|
|
830
932
|
const DATA_PREVIEW_SYSTEM_COLUMN_NAMES = new Set(["updatedAt", "createdAt"]);
|
|
831
|
-
/** Operational
|
|
933
|
+
/** Operational collections have no title/status
|
|
832
934
|
* workflow worth a dedicated column — instead the admin list shows up
|
|
833
935
|
* to 3 raw data columns. Mirrors the client-side column-picking rule
|
|
834
936
|
* in `collection-view.tsx`: first 3 `required` properties, skipping
|
|
@@ -836,7 +938,7 @@ const DATA_PREVIEW_SYSTEM_COLUMN_NAMES = new Set(["updatedAt", "createdAt"]);
|
|
|
836
938
|
* above (#443). Kept small on the wire on purpose — this is a
|
|
837
939
|
* preview, not the full entry. */
|
|
838
940
|
function adminDataPreview(data, manifest) {
|
|
839
|
-
if (!manifest || manifest.spec.lifecycle !== "
|
|
941
|
+
if (!manifest || manifest.spec.lifecycle !== "operational")
|
|
840
942
|
return undefined;
|
|
841
943
|
const schema = manifest.spec.schema;
|
|
842
944
|
// Schema-stable skip (not per-row): rows with a blank title field
|
|
@@ -875,7 +977,7 @@ function isStringTypedSchema(schema) {
|
|
|
875
977
|
const types = Array.isArray(rawType) ? rawType : rawType ? [rawType] : [];
|
|
876
978
|
return types.includes("string");
|
|
877
979
|
}
|
|
878
|
-
function adminListItem(row, schemasByName) {
|
|
980
|
+
function adminListItem(row, schemasByName, translationLocales = []) {
|
|
879
981
|
const manifest = schemasByName.get(row.collection);
|
|
880
982
|
return {
|
|
881
983
|
id: row.id,
|
|
@@ -885,6 +987,7 @@ function adminListItem(row, schemasByName) {
|
|
|
885
987
|
version: row.version,
|
|
886
988
|
title: adminEntryTitle(row.data, manifest?.spec.schema),
|
|
887
989
|
updated_at: row.updatedAt,
|
|
990
|
+
translation_locales: translationLocales,
|
|
888
991
|
data_preview: adminDataPreview(row.data, manifest),
|
|
889
992
|
};
|
|
890
993
|
}
|
|
@@ -938,6 +1041,7 @@ async function entryEditorPayload(runtime, row, schemas) {
|
|
|
938
1041
|
return {
|
|
939
1042
|
collection: adminEditorCollection(schema, schemas),
|
|
940
1043
|
entry: adminEditorEntry(row),
|
|
1044
|
+
parentEntryId: await parentEntryId(runtime, schema, row, schemas),
|
|
941
1045
|
related,
|
|
942
1046
|
};
|
|
943
1047
|
}
|
|
@@ -946,7 +1050,7 @@ function adminEditorCollection(schema, schemas) {
|
|
|
946
1050
|
name: schema.metadata.name,
|
|
947
1051
|
title: schema.spec.title,
|
|
948
1052
|
description: schema.spec.description ?? null,
|
|
949
|
-
lifecycle: schema.spec.lifecycle ?? "
|
|
1053
|
+
lifecycle: schema.spec.lifecycle ?? "publishing",
|
|
950
1054
|
parent: collectionParentFor(schema, schemas),
|
|
951
1055
|
hasTranslations: schemas.some((candidate) => candidate.spec.translates?.parent === schema.metadata.name),
|
|
952
1056
|
localized: schema.spec.localized ?? Boolean(schema.spec.translates),
|
|
@@ -954,6 +1058,8 @@ function adminEditorCollection(schema, schemas) {
|
|
|
954
1058
|
schema: schema.spec.schema,
|
|
955
1059
|
uiSchema: schema.spec.uiSchema ?? null,
|
|
956
1060
|
mediaFields: mediaFieldsForCollection(schema, schemas),
|
|
1061
|
+
sortableFields: schemaSortableFields(schema),
|
|
1062
|
+
filter: checkSchemaListFilter(schema).filter,
|
|
957
1063
|
};
|
|
958
1064
|
}
|
|
959
1065
|
function adminEditorEntry(row) {
|
|
@@ -974,7 +1080,9 @@ async function relatedEntrySections(runtime, parentSchema, parentRow, schemas) {
|
|
|
974
1080
|
const childSchema = schemas.find((schema) => schema.metadata.name === relationship.collection);
|
|
975
1081
|
if (!childSchema)
|
|
976
1082
|
continue;
|
|
977
|
-
const entries =
|
|
1083
|
+
const entries = relationship.parentValue === null
|
|
1084
|
+
? []
|
|
1085
|
+
: await entriesByDataValue(runtime, relationship.collection, relationship.childField, relationship.parentValue);
|
|
978
1086
|
sections.push({
|
|
979
1087
|
collection: adminEditorCollection(childSchema, schemas),
|
|
980
1088
|
relationship: {
|
|
@@ -994,7 +1102,7 @@ function discoverChildRelationships(parentSchema, parentRow, schemas) {
|
|
|
994
1102
|
const seen = new Set();
|
|
995
1103
|
const add = (childSchema, kind, parentField, childField, rawParentValue) => {
|
|
996
1104
|
const parentValue = primitiveJoinValue(rawParentValue);
|
|
997
|
-
if (parentValue == null)
|
|
1105
|
+
if (parentValue == null && kind !== "translation")
|
|
998
1106
|
return;
|
|
999
1107
|
const key = `${childSchema.metadata.name}:${kind}:${parentField}:${childField}:${String(parentValue)}`;
|
|
1000
1108
|
if (seen.has(key))
|
|
@@ -1008,6 +1116,10 @@ function discoverChildRelationships(parentSchema, parentRow, schemas) {
|
|
|
1008
1116
|
parentValue,
|
|
1009
1117
|
});
|
|
1010
1118
|
};
|
|
1119
|
+
const translates = parentSchema.spec.translates;
|
|
1120
|
+
if (translates) {
|
|
1121
|
+
add(parentSchema, "translation", translates.on, translates.on, parentRow.data[translates.on]);
|
|
1122
|
+
}
|
|
1011
1123
|
for (const childSchema of schemas) {
|
|
1012
1124
|
if (childSchema.metadata.name === parentName)
|
|
1013
1125
|
continue;
|
|
@@ -1051,6 +1163,17 @@ function collectionParentFor(childSchema, schemas) {
|
|
|
1051
1163
|
}
|
|
1052
1164
|
return null;
|
|
1053
1165
|
}
|
|
1166
|
+
async function parentEntryId(runtime, childSchema, childRow, schemas) {
|
|
1167
|
+
const parent = collectionParentFor(childSchema, schemas);
|
|
1168
|
+
if (!parent)
|
|
1169
|
+
return null;
|
|
1170
|
+
const value = primitiveJoinValue(childRow.data[parent.childField]);
|
|
1171
|
+
if (value === null)
|
|
1172
|
+
return null;
|
|
1173
|
+
if (parent.parentField === "id" && typeof value === "string")
|
|
1174
|
+
return value;
|
|
1175
|
+
return (await entriesByDataValue(runtime, parent.collection, parent.parentField, value))[0]?.id ?? null;
|
|
1176
|
+
}
|
|
1054
1177
|
function primitiveJoinValue(value) {
|
|
1055
1178
|
if (typeof value === "string" && value !== "")
|
|
1056
1179
|
return value;
|
|
@@ -1060,6 +1183,9 @@ function primitiveJoinValue(value) {
|
|
|
1060
1183
|
return value;
|
|
1061
1184
|
return null;
|
|
1062
1185
|
}
|
|
1186
|
+
function joinValueKey(value) {
|
|
1187
|
+
return `${typeof value}:${String(value)}`;
|
|
1188
|
+
}
|
|
1063
1189
|
async function entriesByDataValue(runtime, collection, field, value) {
|
|
1064
1190
|
return runtime.entryReader.findManyByDataField({
|
|
1065
1191
|
collection,
|
|
@@ -1070,7 +1196,9 @@ async function entriesByDataValue(runtime, collection, field, value) {
|
|
|
1070
1196
|
}
|
|
1071
1197
|
function adminSiteSettings(site) {
|
|
1072
1198
|
return {
|
|
1073
|
-
|
|
1199
|
+
brand: site.brand,
|
|
1200
|
+
title: site.title,
|
|
1201
|
+
description: site.description,
|
|
1074
1202
|
ga4MeasurementId: site.ga4MeasurementId ?? "",
|
|
1075
1203
|
facebookPixelId: site.facebookPixelId ?? "",
|
|
1076
1204
|
};
|
|
@@ -1113,7 +1241,7 @@ async function readStaffGate(c, auth) {
|
|
|
1113
1241
|
const session = await auth.getSession(c.req.raw);
|
|
1114
1242
|
if (!session)
|
|
1115
1243
|
return { kind: "unauth" };
|
|
1116
|
-
const role = await
|
|
1244
|
+
const role = await auth.getUserRole(session.user.id);
|
|
1117
1245
|
const login = session.user.githubLogin ?? null;
|
|
1118
1246
|
if (!role || !STAFF_ROLE_SET.has(role)) {
|
|
1119
1247
|
return { kind: "forbidden", login };
|
|
@@ -1122,6 +1250,7 @@ async function readStaffGate(c, auth) {
|
|
|
1122
1250
|
kind: "ok",
|
|
1123
1251
|
userId: session.user.id,
|
|
1124
1252
|
login,
|
|
1253
|
+
image: session.user.image ?? null,
|
|
1125
1254
|
role: role,
|
|
1126
1255
|
sessionId: session.session.id,
|
|
1127
1256
|
};
|
|
@@ -1140,7 +1269,7 @@ async function handleHttpTrigger(req, runtime, ref, triggerName, triggerPath, pa
|
|
|
1140
1269
|
const caller = await resolveCaller(req, {
|
|
1141
1270
|
auth: ref.auth,
|
|
1142
1271
|
credentialResolver: ref.credentialResolver,
|
|
1143
|
-
|
|
1272
|
+
jwtBearer: ref.jwtBearer,
|
|
1144
1273
|
env,
|
|
1145
1274
|
waitUntil,
|
|
1146
1275
|
});
|
|
@@ -1152,7 +1281,20 @@ async function handleHttpTrigger(req, runtime, ref, triggerName, triggerPath, pa
|
|
|
1152
1281
|
if (rejected)
|
|
1153
1282
|
return rejected;
|
|
1154
1283
|
}
|
|
1155
|
-
|
|
1284
|
+
let body;
|
|
1285
|
+
try {
|
|
1286
|
+
body = await readBody(req);
|
|
1287
|
+
}
|
|
1288
|
+
catch {
|
|
1289
|
+
const diagnostic = runtimeDiagnostic({
|
|
1290
|
+
code: "INPUT_VALIDATION_FAILED",
|
|
1291
|
+
severity: "error",
|
|
1292
|
+
path: `${triggerPathPrefix}#/body`,
|
|
1293
|
+
expected: "valid JSON",
|
|
1294
|
+
message: "HTTP Trigger request body is not valid JSON.",
|
|
1295
|
+
});
|
|
1296
|
+
return Response.json({ ok: false, diagnostic: redactForWire(diagnostic) }, { status: 400 });
|
|
1297
|
+
}
|
|
1156
1298
|
// Spread order matters: URL path params are authoritative for the
|
|
1157
1299
|
// resource identifier (a `DELETE /entries/{id}` body MUST NOT spoof
|
|
1158
1300
|
// `id`). Body fields fill in non-path inputs only.
|
|
@@ -1190,7 +1332,7 @@ mountPath) {
|
|
|
1190
1332
|
const caller = await resolveCaller(req, {
|
|
1191
1333
|
auth: ref.auth,
|
|
1192
1334
|
credentialResolver: ref.credentialResolver,
|
|
1193
|
-
|
|
1335
|
+
jwtBearer: ref.jwtBearer,
|
|
1194
1336
|
env,
|
|
1195
1337
|
waitUntil,
|
|
1196
1338
|
});
|
|
@@ -1252,13 +1394,8 @@ async function readBody(req) {
|
|
|
1252
1394
|
const ct = req.headers.get("content-type") ?? "";
|
|
1253
1395
|
if (!ct.includes("json"))
|
|
1254
1396
|
return {};
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
return parsed ?? {};
|
|
1258
|
-
}
|
|
1259
|
-
catch {
|
|
1260
|
-
return {};
|
|
1261
|
-
}
|
|
1397
|
+
const parsed = await req.json();
|
|
1398
|
+
return parsed ?? {};
|
|
1262
1399
|
}
|
|
1263
1400
|
function openApiToHono(path) {
|
|
1264
1401
|
return path.replace(/\{([^}]+)\}/g, ":$1");
|
|
@@ -1319,18 +1456,22 @@ function adminUnauthenticated(c, path) {
|
|
|
1319
1456
|
// instead of bouncing them back to /admin/sign-in (which the OAuth
|
|
1320
1457
|
// re-auth then silently fast-forwards through, producing a visible
|
|
1321
1458
|
// 5-step redirect chain that looks like an infinite loop).
|
|
1322
|
-
function
|
|
1459
|
+
function adminInsufficientRole(c, path, minimumRole) {
|
|
1460
|
+
const diagnostic = adminRoleDiagnostic(`${c.req.method} ${path}`, minimumRole, `This action requires the ${minimumRole} role.`);
|
|
1323
1461
|
return Response.json({
|
|
1324
1462
|
ok: false,
|
|
1325
|
-
diagnostic
|
|
1326
|
-
code: "AUTH_DENIED",
|
|
1327
|
-
severity: "error",
|
|
1328
|
-
path: `${c.req.method} ${path}`,
|
|
1329
|
-
expected: "owner role for the signed-in user",
|
|
1330
|
-
message: "Staff management is owner-only.",
|
|
1331
|
-
}),
|
|
1463
|
+
diagnostic,
|
|
1332
1464
|
}, { status: 403 });
|
|
1333
1465
|
}
|
|
1466
|
+
function adminRoleDiagnostic(path, minimumRole, message) {
|
|
1467
|
+
return runtimeDiagnostic({
|
|
1468
|
+
code: "AUTH_DENIED",
|
|
1469
|
+
severity: "error",
|
|
1470
|
+
path,
|
|
1471
|
+
expected: `${minimumRole} role or higher for the signed-in user`,
|
|
1472
|
+
message,
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1334
1475
|
function adminNotStaff(c, path, login) {
|
|
1335
1476
|
return Response.json({
|
|
1336
1477
|
ok: false,
|