@bobbykim/manguito-cms-api 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,3228 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ createAPIAdapter: () => createAPIAdapter,
34
+ createCmsApp: () => createCmsApp,
35
+ createServer: () => createServer
36
+ });
37
+ module.exports = __toCommonJS(index_exports);
38
+
39
+ // src/app.ts
40
+ var import_hono = require("hono");
41
+ var import_drizzle_orm10 = require("drizzle-orm");
42
+
43
+ // src/middleware/cors.ts
44
+ function createCorsMiddleware(corsConfig) {
45
+ const allowList = Array.isArray(corsConfig.origin) ? corsConfig.origin : [corsConfig.origin];
46
+ const wildcard = allowList.includes("*");
47
+ const methods = corsConfig.methods?.join(",") ?? "GET,POST,PATCH,DELETE,OPTIONS";
48
+ return async function corsMiddleware(c, next) {
49
+ if (corsConfig.enabled === false) {
50
+ return next();
51
+ }
52
+ const requestOrigin = c.req.header("origin");
53
+ if (wildcard) {
54
+ c.res.headers.set("Access-Control-Allow-Origin", "*");
55
+ } else if (requestOrigin && allowList.includes(requestOrigin)) {
56
+ c.res.headers.set("Access-Control-Allow-Origin", requestOrigin);
57
+ c.res.headers.set("Vary", "Origin");
58
+ if (corsConfig.credentials === true) {
59
+ c.res.headers.set("Access-Control-Allow-Credentials", "true");
60
+ }
61
+ }
62
+ c.res.headers.set("Access-Control-Allow-Methods", methods);
63
+ c.res.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
64
+ if (c.req.method === "OPTIONS") {
65
+ return c.newResponse(null, 204);
66
+ }
67
+ return next();
68
+ };
69
+ }
70
+
71
+ // src/middleware/security-headers.ts
72
+ function createSecurityHeadersMiddleware(options = {}) {
73
+ const connectSrc = ["'self'", ...options.connectSrc ?? []].join(" ");
74
+ const csp = [
75
+ "default-src 'self'",
76
+ "img-src 'self' data: https:",
77
+ "media-src 'self' https:",
78
+ "style-src 'self' 'unsafe-inline'",
79
+ "script-src 'self'",
80
+ "font-src 'self' data:",
81
+ `connect-src ${connectSrc}`,
82
+ "frame-ancestors 'none'",
83
+ "base-uri 'self'",
84
+ "form-action 'self'"
85
+ ].join("; ");
86
+ return async function securityHeaders(c, next) {
87
+ await next();
88
+ c.res.headers.set("X-Content-Type-Options", "nosniff");
89
+ c.res.headers.set("X-Frame-Options", "DENY");
90
+ c.res.headers.set("Referrer-Policy", "no-referrer");
91
+ c.res.headers.set("Content-Security-Policy", csp);
92
+ };
93
+ }
94
+
95
+ // src/middleware/error.ts
96
+ var ERROR_STATUS_MAP = {
97
+ NOT_FOUND: 404,
98
+ SLUG_NOT_FOUND: 404,
99
+ METHOD_NOT_ALLOWED: 405,
100
+ VALIDATION_ERROR: 422,
101
+ INVALID_SLUG_FORMAT: 422,
102
+ SLUG_CONFLICT: 409,
103
+ PUBLISH_VALIDATION_ERROR: 422,
104
+ SINGLETON_ALREADY_EXISTS: 409,
105
+ UNAUTHORIZED: 401,
106
+ TOKEN_EXPIRED: 401,
107
+ TOKEN_INVALID: 401,
108
+ INSUFFICIENT_PERMISSION: 403,
109
+ INSUFFICIENT_PRIVILEGE: 403,
110
+ INVALID_FILTER_FIELD: 400,
111
+ INVALID_FILTER_OPERATOR: 400,
112
+ INVALID_SORT_FIELD: 400,
113
+ INVALID_PAGINATION: 400,
114
+ INVALID_INCLUDE_FIELD: 400,
115
+ UNSUPPORTED_MIME_TYPE: 415,
116
+ STORAGE_ERROR: 502,
117
+ MEDIA_IN_USE: 409,
118
+ PRESIGNED_URL_EXPIRED: 410,
119
+ RATE_LIMITED: 429,
120
+ INTERNAL_ERROR: 500
121
+ };
122
+ var errorHandler = (err, c) => {
123
+ console.error(err.stack ?? err.message);
124
+ const apiErr = err;
125
+ const code = apiErr.code ?? "INTERNAL_ERROR";
126
+ const status = ERROR_STATUS_MAP[code] ?? 500;
127
+ return c.json({ ok: false, error: { code, message: err.message } }, status);
128
+ };
129
+
130
+ // src/middleware/auth.ts
131
+ var import_cookie2 = require("hono/cookie");
132
+ var import_drizzle_orm = require("drizzle-orm");
133
+
134
+ // src/auth/jwt.ts
135
+ var import_jwt = require("hono/jwt");
136
+ var import_cookie = require("hono/cookie");
137
+ var AUTH_TOKEN_TTL = 2 * 60 * 60;
138
+ var REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60;
139
+ var PROACTIVE_REFRESH_THRESHOLD = 30 * 60;
140
+ async function signToken(payload, expiresInSeconds) {
141
+ const secret = process.env["AUTH_SECRET"];
142
+ if (!secret) throw new Error("AUTH_SECRET environment variable is not set");
143
+ const fullPayload = {
144
+ ...payload,
145
+ expires_at: Math.floor(Date.now() / 1e3) + expiresInSeconds
146
+ };
147
+ return (0, import_jwt.sign)(fullPayload, secret);
148
+ }
149
+ async function verifyToken(token) {
150
+ const secret = process.env["AUTH_SECRET"];
151
+ if (!secret) throw new Error("AUTH_SECRET environment variable is not set");
152
+ let raw;
153
+ try {
154
+ raw = await (0, import_jwt.verify)(token, secret, "HS256");
155
+ } catch {
156
+ throw Object.assign(new Error("Token signature is invalid"), {
157
+ code: "TOKEN_INVALID"
158
+ });
159
+ }
160
+ const payload = raw;
161
+ if (payload.expires_at < Math.floor(Date.now() / 1e3)) {
162
+ throw Object.assign(new Error("Token has expired"), {
163
+ code: "TOKEN_EXPIRED"
164
+ });
165
+ }
166
+ return payload;
167
+ }
168
+ function setAuthCookie(c, name, token, options) {
169
+ (0, import_cookie.setCookie)(c, name, token, {
170
+ ...options,
171
+ httpOnly: true,
172
+ secure: true,
173
+ sameSite: "Strict"
174
+ });
175
+ }
176
+ function clearAuthCookie(c, name, path) {
177
+ (0, import_cookie.deleteCookie)(c, name, path !== void 0 ? { path } : void 0);
178
+ }
179
+
180
+ // src/middleware/auth.ts
181
+ function createAuthMiddleware(db) {
182
+ return async (c, next) => {
183
+ const token = (0, import_cookie2.getCookie)(c, "auth_token");
184
+ if (!token) {
185
+ return c.json(
186
+ { ok: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } },
187
+ 401
188
+ );
189
+ }
190
+ let payload;
191
+ try {
192
+ payload = await verifyToken(token);
193
+ } catch (err) {
194
+ const code = err.code;
195
+ if (code === "TOKEN_EXPIRED") {
196
+ return c.json(
197
+ { ok: false, error: { code: "TOKEN_EXPIRED", message: "Token has expired" } },
198
+ 401
199
+ );
200
+ }
201
+ return c.json(
202
+ { ok: false, error: { code: "TOKEN_INVALID", message: "Token signature is invalid" } },
203
+ 401
204
+ );
205
+ }
206
+ const result = await db.execute(
207
+ import_drizzle_orm.sql`SELECT token_version, must_change_password FROM users WHERE id = ${payload.user_id} LIMIT 1`
208
+ );
209
+ const row = result.rows[0];
210
+ if (!row) {
211
+ return c.json(
212
+ { ok: false, error: { code: "TOKEN_INVALID", message: "Token signature is invalid" } },
213
+ 401
214
+ );
215
+ }
216
+ if (payload.token_version !== row.token_version) {
217
+ return c.json(
218
+ { ok: false, error: { code: "TOKEN_INVALID", message: "Token has been invalidated" } },
219
+ 401
220
+ );
221
+ }
222
+ c.set("user", {
223
+ id: payload.user_id,
224
+ role: payload.role,
225
+ must_change_password: row.must_change_password
226
+ });
227
+ const now = Math.floor(Date.now() / 1e3);
228
+ if (payload.expires_at < now + PROACTIVE_REFRESH_THRESHOLD) {
229
+ const newToken = await signToken(
230
+ {
231
+ user_id: payload.user_id,
232
+ role: payload.role,
233
+ token_version: payload.token_version
234
+ },
235
+ AUTH_TOKEN_TTL
236
+ );
237
+ setAuthCookie(c, "auth_token", newToken, {});
238
+ }
239
+ await next();
240
+ };
241
+ }
242
+
243
+ // src/middleware/must-change-password.ts
244
+ var mustChangePasswordCheck = async (c, next) => {
245
+ const user = c.get("user");
246
+ if (user?.must_change_password === true && c.req.path !== "/admin/api/users/change-password") {
247
+ return c.json(
248
+ {
249
+ ok: false,
250
+ error: {
251
+ code: "PASSWORD_CHANGE_REQUIRED",
252
+ message: "You must change your password before continuing."
253
+ }
254
+ },
255
+ 403
256
+ );
257
+ }
258
+ await next();
259
+ };
260
+
261
+ // src/middleware/permission.ts
262
+ function createPermissionMiddleware(registry) {
263
+ return function requirePermission(permission) {
264
+ return async (c, next) => {
265
+ const user = c.get("user");
266
+ const role = user ? registry[user.role] : void 0;
267
+ if (!role || !role.permissions.includes(permission)) {
268
+ return c.json(
269
+ {
270
+ ok: false,
271
+ error: { code: "INSUFFICIENT_PERMISSION", message: "Insufficient permission" }
272
+ },
273
+ 403
274
+ );
275
+ }
276
+ await next();
277
+ };
278
+ };
279
+ }
280
+
281
+ // src/middleware/hierarchy.ts
282
+ function createHierarchyMiddleware(registry, getUserRole) {
283
+ return function requireHierarchy() {
284
+ return async (c, next) => {
285
+ const user = c.get("user");
286
+ const actingRole = user ? registry[user.role] : void 0;
287
+ if (!actingRole) {
288
+ return c.json(
289
+ {
290
+ ok: false,
291
+ error: { code: "INSUFFICIENT_PRIVILEGE", message: "Insufficient privilege" }
292
+ },
293
+ 403
294
+ );
295
+ }
296
+ const method = c.req.method;
297
+ const targetId = c.req.param("id");
298
+ let targetRoleName = null;
299
+ if (method === "POST" && !targetId) {
300
+ let body = {};
301
+ try {
302
+ body = await c.req.json();
303
+ } catch {
304
+ }
305
+ targetRoleName = typeof body.role === "string" ? body.role : null;
306
+ if (!targetRoleName) {
307
+ return c.json(
308
+ {
309
+ ok: false,
310
+ error: { code: "INVALID_ROLE", message: "Request body must include a valid role" }
311
+ },
312
+ 400
313
+ );
314
+ }
315
+ } else if (method === "PATCH" && targetId) {
316
+ let bodyRole;
317
+ try {
318
+ const body = await c.req.json();
319
+ if (typeof body.role === "string") bodyRole = body.role;
320
+ } catch {
321
+ }
322
+ targetRoleName = bodyRole ?? await getUserRole(targetId);
323
+ } else if (targetId) {
324
+ targetRoleName = await getUserRole(targetId);
325
+ }
326
+ if (targetRoleName === null) {
327
+ return next();
328
+ }
329
+ const targetRole = registry[targetRoleName];
330
+ if (!targetRole) {
331
+ return c.json(
332
+ {
333
+ ok: false,
334
+ error: { code: "INVALID_ROLE", message: `Role "${targetRoleName}" is not valid` }
335
+ },
336
+ 400
337
+ );
338
+ }
339
+ if (actingRole.hierarchy_level >= targetRole.hierarchy_level) {
340
+ return c.json(
341
+ {
342
+ ok: false,
343
+ error: { code: "INSUFFICIENT_PRIVILEGE", message: "Insufficient privilege" }
344
+ },
345
+ 403
346
+ );
347
+ }
348
+ await next();
349
+ };
350
+ };
351
+ }
352
+
353
+ // src/middleware/rate-limit.ts
354
+ var DEFAULT_WINDOW_MS = 6e4;
355
+ var DEFAULT_MAX_PER_IP = 30;
356
+ var DEFAULT_MAX_GLOBAL = 500;
357
+ function createRateLimitMiddleware(options) {
358
+ const { windowMs, maxPerIp, maxGlobal } = options;
359
+ const ipRequests = /* @__PURE__ */ new Map();
360
+ const globalRequests = [];
361
+ return async function rateLimitMiddleware(c, next) {
362
+ const authToken = c.req.raw.headers.get("cookie")?.split(";").map((s) => s.trim()).find((s) => s.startsWith("auth_token="));
363
+ if (authToken) {
364
+ return next();
365
+ }
366
+ const now = Date.now();
367
+ const cutoff = now - windowMs;
368
+ let gi = 0;
369
+ while (gi < globalRequests.length && globalRequests[gi] < cutoff) gi++;
370
+ globalRequests.splice(0, gi);
371
+ const forwarded = c.req.header("x-forwarded-for");
372
+ const ip = forwarded ? forwarded.split(",")[0].trim() : c.env?.incoming?.socket?.remoteAddress ?? "unknown";
373
+ const ipTimes = ipRequests.get(ip) ?? [];
374
+ let ii = 0;
375
+ while (ii < ipTimes.length && ipTimes[ii] < cutoff) ii++;
376
+ ipTimes.splice(0, ii);
377
+ ipRequests.set(ip, ipTimes);
378
+ const retryAfterFromGlobal = globalRequests.length > 0 ? Math.ceil((globalRequests[0] + windowMs - now) / 1e3) : 0;
379
+ const retryAfterFromIp = ipTimes.length > 0 ? Math.ceil((ipTimes[0] + windowMs - now) / 1e3) : 0;
380
+ if (globalRequests.length >= maxGlobal) {
381
+ const retryAfter = Math.max(retryAfterFromGlobal, 1);
382
+ const reset = Math.floor((now + retryAfter * 1e3) / 1e3);
383
+ return c.json(
384
+ { ok: false, error: { code: "RATE_LIMITED", message: `Too many requests. Please retry after ${retryAfter} seconds.` } },
385
+ 429,
386
+ {
387
+ "Retry-After": String(retryAfter),
388
+ "X-RateLimit-Limit": String(maxGlobal),
389
+ "X-RateLimit-Remaining": "0",
390
+ "X-RateLimit-Reset": String(reset)
391
+ }
392
+ );
393
+ }
394
+ if (ipTimes.length >= maxPerIp) {
395
+ const retryAfter = Math.max(retryAfterFromIp, 1);
396
+ const reset = Math.floor((now + retryAfter * 1e3) / 1e3);
397
+ return c.json(
398
+ { ok: false, error: { code: "RATE_LIMITED", message: `Too many requests. Please retry after ${retryAfter} seconds.` } },
399
+ 429,
400
+ {
401
+ "Retry-After": String(retryAfter),
402
+ "X-RateLimit-Limit": String(maxPerIp),
403
+ "X-RateLimit-Remaining": "0",
404
+ "X-RateLimit-Reset": String(reset)
405
+ }
406
+ );
407
+ }
408
+ globalRequests.push(now);
409
+ ipTimes.push(now);
410
+ return next();
411
+ };
412
+ }
413
+ function resolveListRateLimit(rateLimit) {
414
+ const findAll = rateLimit?.findAll;
415
+ if (findAll === "*") {
416
+ return void 0;
417
+ }
418
+ return createRateLimitMiddleware({
419
+ windowMs: findAll?.windowMs ?? DEFAULT_WINDOW_MS,
420
+ maxPerIp: findAll?.maxPerIp ?? DEFAULT_MAX_PER_IP,
421
+ maxGlobal: findAll?.maxGlobal ?? DEFAULT_MAX_GLOBAL
422
+ });
423
+ }
424
+
425
+ // src/auth/registry.ts
426
+ var SYSTEM_ROLES = ["admin", "manager", "editor", "writer", "viewer"];
427
+ function validateRoles(roles) {
428
+ if (roles.length === 0) {
429
+ throw new Error(
430
+ "Fatal: roles registry failed to build \u2014 roles array is empty. Run `manguito validate` to check your roles schema."
431
+ );
432
+ }
433
+ const nameSet = new Set(roles.map((r) => r.name));
434
+ for (const systemRole of SYSTEM_ROLES) {
435
+ if (!nameSet.has(systemRole)) {
436
+ throw new Error(
437
+ `Fatal: roles registry failed to build \u2014 missing system role "${systemRole}". Run \`manguito validate\` to check your roles schema.`
438
+ );
439
+ }
440
+ }
441
+ const levelsSeen = /* @__PURE__ */ new Map();
442
+ for (const role of roles) {
443
+ const existing = levelsSeen.get(role.hierarchy_level);
444
+ if (existing !== void 0) {
445
+ throw new Error(
446
+ `Fatal: roles registry failed to build \u2014 duplicate hierarchy_level ${role.hierarchy_level} on roles "${existing}" and "${role.name}". Run \`manguito validate\` to check your roles schema.`
447
+ );
448
+ }
449
+ levelsSeen.set(role.hierarchy_level, role.name);
450
+ }
451
+ const namesSeen = /* @__PURE__ */ new Set();
452
+ for (const role of roles) {
453
+ if (namesSeen.has(role.name)) {
454
+ throw new Error(
455
+ `Fatal: roles registry failed to build \u2014 duplicate role name "${role.name}". Run \`manguito validate\` to check your roles schema.`
456
+ );
457
+ }
458
+ namesSeen.add(role.name);
459
+ }
460
+ }
461
+ function buildRolesRegistry(roles) {
462
+ validateRoles(roles);
463
+ return Object.fromEntries(roles.map((r) => [r.name, r]));
464
+ }
465
+
466
+ // src/routes/query-params.ts
467
+ var SORTABLE_FIELDS = /* @__PURE__ */ new Set(["title", "created_at", "updated_at"]);
468
+ var RELATION_FIELD_TYPES = /* @__PURE__ */ new Set([
469
+ "paragraph",
470
+ "reference",
471
+ "image",
472
+ "video",
473
+ "file"
474
+ ]);
475
+ function parsePagination(pageStr, perPageStr) {
476
+ const page = pageStr !== void 0 ? Number(pageStr) : 1;
477
+ const per_page = perPageStr !== void 0 ? Number(perPageStr) : 10;
478
+ if (!Number.isInteger(page) || page < 1) return { ok: false };
479
+ if (!Number.isInteger(per_page) || per_page < 1 || per_page > 100) return { ok: false };
480
+ return { ok: true, page, per_page };
481
+ }
482
+ function parseInclude(includeParam) {
483
+ if (!includeParam) return [];
484
+ return includeParam.split(",").map((s) => s.trim()).filter(Boolean);
485
+ }
486
+ function parseFilters(url, validFields) {
487
+ const { searchParams } = new URL(url);
488
+ const filters = {};
489
+ for (const [key, value] of searchParams.entries()) {
490
+ const simpleMatch = /^filter\[([^\]]+)\]$/.exec(key);
491
+ const opMatch = /^filter\[([^\]]+)\]\[([^\]]+)\]$/.exec(key);
492
+ if (simpleMatch) {
493
+ const field = simpleMatch[1];
494
+ if (!validFields.has(field)) return { ok: false, invalidField: field };
495
+ const existing = filters[field];
496
+ if (existing !== void 0) {
497
+ filters[field] = Array.isArray(existing) ? [...existing, value] : [existing, value];
498
+ } else {
499
+ filters[field] = value;
500
+ }
501
+ } else if (opMatch) {
502
+ const field = opMatch[1];
503
+ const operator = opMatch[2];
504
+ if (!validFields.has(field)) return { ok: false, invalidField: field };
505
+ if (!["gt", "gte", "lt", "lte"].includes(operator)) continue;
506
+ const existing = filters[field] ?? {};
507
+ filters[field] = { ...existing, [operator]: value };
508
+ }
509
+ }
510
+ return { ok: true, filters };
511
+ }
512
+
513
+ // src/routes/content.ts
514
+ function isPublished(item) {
515
+ return item["published"] === true;
516
+ }
517
+ function registerPublicContentRoutes(app, registry, repos, listRateLimit) {
518
+ function registerListRoute(path, handler) {
519
+ if (listRateLimit) {
520
+ app.get(path, listRateLimit, handler);
521
+ } else {
522
+ app.get(path, handler);
523
+ }
524
+ }
525
+ registerListRoute("/api/content", (c) => {
526
+ const data = Object.values(registry.content_types).map((ct) => ({
527
+ name: ct.name,
528
+ label: ct.label,
529
+ only_one: ct.only_one
530
+ }));
531
+ return c.json({ ok: true, data });
532
+ });
533
+ registerListRoute("/api/taxonomy", (c) => {
534
+ const data = Object.values(registry.taxonomy_types).map((tt) => ({
535
+ name: tt.name,
536
+ label: tt.label
537
+ }));
538
+ return c.json({ ok: true, data });
539
+ });
540
+ for (const [typeName, contentType] of Object.entries(registry.content_types)) {
541
+ const basePath = contentType.default_base_path;
542
+ const repo = repos[typeName];
543
+ if (!repo) continue;
544
+ const schemaFieldNames = /* @__PURE__ */ new Set([
545
+ ...contentType.fields.map((f) => f.name),
546
+ ...contentType.system_fields.map((f) => f.name)
547
+ ]);
548
+ const relationFieldNames = new Set(
549
+ contentType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
550
+ );
551
+ if (contentType.only_one) {
552
+ app.get(`/api/${basePath}`, async (c) => {
553
+ const result = await repo.findMany({ published_only: true, page: 1, per_page: 1 });
554
+ if (result.data.length === 0) {
555
+ return c.json(
556
+ { ok: false, error: { code: "NOT_FOUND", message: "Not found" } },
557
+ 404
558
+ );
559
+ }
560
+ return c.json({ ok: true, data: result.data[0] });
561
+ });
562
+ } else {
563
+ registerListRoute(`/api/${basePath}`, async (c) => {
564
+ const pagination = parsePagination(c.req.query("page"), c.req.query("per_page"));
565
+ if (!pagination.ok) {
566
+ return c.json(
567
+ {
568
+ ok: false,
569
+ error: {
570
+ code: "INVALID_PAGINATION",
571
+ message: "page must be \u2265 1 and per_page must be between 1 and 100"
572
+ }
573
+ },
574
+ 400
575
+ );
576
+ }
577
+ const sortBy = c.req.query("sort_by") ?? "created_at";
578
+ if (!SORTABLE_FIELDS.has(sortBy)) {
579
+ return c.json(
580
+ {
581
+ ok: false,
582
+ error: {
583
+ code: "INVALID_SORT_FIELD",
584
+ message: `'${sortBy}' is not sortable. Allowed: title, created_at, updated_at`
585
+ }
586
+ },
587
+ 400
588
+ );
589
+ }
590
+ const sortOrder = c.req.query("sort_order") ?? "asc";
591
+ if (sortOrder !== "asc" && sortOrder !== "desc") {
592
+ return c.json(
593
+ {
594
+ ok: false,
595
+ error: {
596
+ code: "INVALID_SORT_FIELD",
597
+ message: `sort_order must be 'asc' or 'desc'`
598
+ }
599
+ },
600
+ 400
601
+ );
602
+ }
603
+ const filtersResult = parseFilters(c.req.url, schemaFieldNames);
604
+ if (!filtersResult.ok) {
605
+ return c.json(
606
+ {
607
+ ok: false,
608
+ error: {
609
+ code: "INVALID_FILTER_FIELD",
610
+ message: `Filter field '${filtersResult.invalidField}' does not exist on this content type`
611
+ }
612
+ },
613
+ 400
614
+ );
615
+ }
616
+ const include = parseInclude(c.req.query("include"));
617
+ for (const field of include) {
618
+ if (!relationFieldNames.has(field)) {
619
+ return c.json(
620
+ {
621
+ ok: false,
622
+ error: {
623
+ code: "INVALID_INCLUDE_FIELD",
624
+ message: `'${field}' is not a valid relation field`
625
+ }
626
+ },
627
+ 400
628
+ );
629
+ }
630
+ }
631
+ const result = await repo.findMany({
632
+ published_only: true,
633
+ page: pagination.page,
634
+ per_page: pagination.per_page,
635
+ sort_by: sortBy,
636
+ sort_order: sortOrder,
637
+ filters: filtersResult.filters,
638
+ include
639
+ });
640
+ return c.json(result);
641
+ });
642
+ app.get(`/api/${basePath}/:slug`, async (c) => {
643
+ const slug = c.req.param("slug");
644
+ const include = parseInclude(c.req.query("include"));
645
+ for (const field of include) {
646
+ if (!relationFieldNames.has(field)) {
647
+ return c.json(
648
+ {
649
+ ok: false,
650
+ error: {
651
+ code: "INVALID_INCLUDE_FIELD",
652
+ message: `'${field}' is not a valid relation field`
653
+ }
654
+ },
655
+ 400
656
+ );
657
+ }
658
+ }
659
+ const item = await repo.findBySlug(slug, include);
660
+ if (!item || !isPublished(item)) {
661
+ return c.json(
662
+ {
663
+ ok: false,
664
+ error: { code: "SLUG_NOT_FOUND", message: `No item found with slug '${slug}'` }
665
+ },
666
+ 404
667
+ );
668
+ }
669
+ return c.json({ ok: true, data: item });
670
+ });
671
+ }
672
+ }
673
+ for (const [typeName] of Object.entries(registry.taxonomy_types)) {
674
+ const repo = repos[typeName];
675
+ if (!repo) continue;
676
+ registerListRoute(`/api/taxonomy/${typeName}`, async (c) => {
677
+ const pagination = parsePagination(c.req.query("page"), c.req.query("per_page"));
678
+ if (!pagination.ok) {
679
+ return c.json(
680
+ {
681
+ ok: false,
682
+ error: {
683
+ code: "INVALID_PAGINATION",
684
+ message: "page must be \u2265 1 and per_page must be between 1 and 100"
685
+ }
686
+ },
687
+ 400
688
+ );
689
+ }
690
+ const result = await repo.findMany({
691
+ published_only: true,
692
+ page: pagination.page,
693
+ per_page: pagination.per_page
694
+ });
695
+ return c.json(result);
696
+ });
697
+ app.get(`/api/taxonomy/${typeName}/:id`, async (c) => {
698
+ const id = c.req.param("id");
699
+ const item = await repo.findOne(id);
700
+ if (!item || !isPublished(item)) {
701
+ return c.json(
702
+ {
703
+ ok: false,
704
+ error: { code: "NOT_FOUND", message: "Taxonomy term not found" }
705
+ },
706
+ 404
707
+ );
708
+ }
709
+ return c.json({ ok: true, data: item });
710
+ });
711
+ }
712
+ }
713
+
714
+ // src/routes/media.ts
715
+ var VALID_MEDIA_TYPES = /* @__PURE__ */ new Set(["image", "video", "file"]);
716
+ function parsePagination2(pageStr, perPageStr) {
717
+ const page = pageStr !== void 0 ? Number(pageStr) : 1;
718
+ const per_page = perPageStr !== void 0 ? Number(perPageStr) : 10;
719
+ if (!Number.isInteger(page) || page < 1) return { ok: false };
720
+ if (!Number.isInteger(per_page) || per_page < 1 || per_page > 100) return { ok: false };
721
+ return { ok: true, page, per_page };
722
+ }
723
+ function registerPublicMediaRoutes(app, mediaRepo, listRateLimit) {
724
+ const mediaListHandler = async (c) => {
725
+ const pagination = parsePagination2(c.req.query("page"), c.req.query("per_page"));
726
+ if (!pagination.ok) {
727
+ return c.json(
728
+ {
729
+ ok: false,
730
+ error: {
731
+ code: "INVALID_PAGINATION",
732
+ message: "page must be \u2265 1 and per_page must be between 1 and 100"
733
+ }
734
+ },
735
+ 400
736
+ );
737
+ }
738
+ const typeParam = c.req.query("type");
739
+ if (typeParam !== void 0 && !VALID_MEDIA_TYPES.has(typeParam)) {
740
+ return c.json(
741
+ {
742
+ ok: false,
743
+ error: {
744
+ code: "VALIDATION_ERROR",
745
+ message: `type must be one of: image, video, file`
746
+ }
747
+ },
748
+ 422
749
+ );
750
+ }
751
+ const opts = {
752
+ page: pagination.page,
753
+ per_page: pagination.per_page
754
+ };
755
+ if (typeParam !== void 0) {
756
+ opts.type = typeParam;
757
+ }
758
+ const result = await mediaRepo.findMany(opts);
759
+ return c.json(result);
760
+ };
761
+ if (listRateLimit) {
762
+ app.get("/api/media", listRateLimit, mediaListHandler);
763
+ } else {
764
+ app.get("/api/media", mediaListHandler);
765
+ }
766
+ app.get("/api/media/:id", async (c) => {
767
+ const id = c.req.param("id");
768
+ const item = await mediaRepo.findOne(id);
769
+ if (!item) {
770
+ return c.json(
771
+ { ok: false, error: { code: "NOT_FOUND", message: "Media item not found" } },
772
+ 404
773
+ );
774
+ }
775
+ return c.json({ ok: true, data: item });
776
+ });
777
+ }
778
+
779
+ // src/routes/admin/content.ts
780
+ var import_drizzle_orm3 = require("drizzle-orm");
781
+
782
+ // src/media-references.ts
783
+ function extractMediaId(value) {
784
+ return typeof value === "string" && value !== "" ? value : null;
785
+ }
786
+ function topLevelMediaDelta(mediaFields, before, after) {
787
+ const added = [];
788
+ const removed = [];
789
+ for (const f of mediaFields) {
790
+ if (after !== null && !(f.name in after)) continue;
791
+ const oldId = before ? extractMediaId(before[f.name]) : null;
792
+ const newId = after ? extractMediaId(after[f.name]) : null;
793
+ if (newId === oldId) continue;
794
+ if (oldId) removed.push(oldId);
795
+ if (newId) added.push(newId);
796
+ }
797
+ return { added, removed };
798
+ }
799
+ function mergeMediaDeltas(...deltas) {
800
+ return {
801
+ added: deltas.flatMap((d) => d.added),
802
+ removed: deltas.flatMap((d) => d.removed)
803
+ };
804
+ }
805
+ async function applyMediaReferenceDelta(delta, mediaRepo) {
806
+ const added = new Set(delta.added);
807
+ const removed = new Set(delta.removed);
808
+ const netAdd = [...added].filter((id) => !removed.has(id));
809
+ const netRemove = [...removed].filter((id) => !added.has(id));
810
+ if (netAdd.length > 0) await mediaRepo.incrementReferenceCount(netAdd);
811
+ if (netRemove.length > 0) await mediaRepo.decrementReferenceCount(netRemove);
812
+ }
813
+
814
+ // src/relations.ts
815
+ var import_node_crypto = __toESM(require("crypto"));
816
+ var import_drizzle_orm2 = require("drizzle-orm");
817
+ function quoteIdent(name) {
818
+ if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`Unsafe identifier: ${name}`);
819
+ return `"${name}"`;
820
+ }
821
+ function paragraphMediaFieldNames(pType) {
822
+ return new Set(
823
+ pType.fields.filter((f) => f.field_type === "image" || f.field_type === "video" || f.field_type === "file").map((f) => f.name)
824
+ );
825
+ }
826
+ async function fetchParagraphMediaIds(db, pType, itemId, fieldName) {
827
+ const mediaColumns = pType.fields.filter((f) => f.field_type === "image" || f.field_type === "video" || f.field_type === "file").map((f) => f.db_column.column_name);
828
+ if (mediaColumns.length === 0) return [];
829
+ const tbl = import_drizzle_orm2.sql.raw(quoteIdent(pType.db.table_name));
830
+ const cols = import_drizzle_orm2.sql.join(mediaColumns.map((c) => import_drizzle_orm2.sql.raw(quoteIdent(c))), import_drizzle_orm2.sql`, `);
831
+ const result = await db.execute(
832
+ import_drizzle_orm2.sql`SELECT ${cols} FROM ${tbl} WHERE parent_id = ${itemId} AND parent_field = ${fieldName}`
833
+ );
834
+ const ids = [];
835
+ for (const row of result.rows) {
836
+ for (const col of mediaColumns) {
837
+ const v = row[col];
838
+ if (typeof v === "string" && v !== "") ids.push(v);
839
+ }
840
+ }
841
+ return ids;
842
+ }
843
+ async function persistParagraphField(db, itemId, parentType, fieldName, pType, items) {
844
+ const removed = await fetchParagraphMediaIds(db, pType, itemId, fieldName);
845
+ const tbl = import_drizzle_orm2.sql.raw(quoteIdent(pType.db.table_name));
846
+ await db.execute(import_drizzle_orm2.sql`DELETE FROM ${tbl} WHERE parent_id = ${itemId} AND parent_field = ${fieldName}`);
847
+ const mediaFieldNames = paragraphMediaFieldNames(pType);
848
+ const added = [];
849
+ for (let i = 0; i < items.length; i++) {
850
+ const pItem = items[i];
851
+ const data = {
852
+ id: import_node_crypto.default.randomUUID(),
853
+ parent_id: itemId,
854
+ parent_type: parentType,
855
+ parent_field: fieldName,
856
+ order: i,
857
+ created_at: /* @__PURE__ */ new Date(),
858
+ updated_at: /* @__PURE__ */ new Date()
859
+ };
860
+ for (const pf of pType.fields) {
861
+ if (!pf.db_column || pf.db_column.junction) continue;
862
+ data[pf.db_column.column_name] = pItem[pf.name] ?? null;
863
+ }
864
+ const cols = import_drizzle_orm2.sql.join(Object.keys(data).map((k) => import_drizzle_orm2.sql.raw(quoteIdent(k))), import_drizzle_orm2.sql`, `);
865
+ const vals = import_drizzle_orm2.sql.join(Object.values(data).map((v) => import_drizzle_orm2.sql`${v}`), import_drizzle_orm2.sql`, `);
866
+ await db.execute(import_drizzle_orm2.sql`INSERT INTO ${tbl} (${cols}) VALUES (${vals})`);
867
+ for (const name of mediaFieldNames) {
868
+ const val = pItem[name];
869
+ if (typeof val === "string" && val !== "") added.push(val);
870
+ }
871
+ }
872
+ return { added, removed };
873
+ }
874
+ async function deleteParagraphField(db, itemId, fieldName, pType) {
875
+ const removed = await fetchParagraphMediaIds(db, pType, itemId, fieldName);
876
+ const tbl = import_drizzle_orm2.sql.raw(quoteIdent(pType.db.table_name));
877
+ await db.execute(import_drizzle_orm2.sql`DELETE FROM ${tbl} WHERE parent_id = ${itemId} AND parent_field = ${fieldName}`);
878
+ return { added: [], removed };
879
+ }
880
+ async function persistJunctionField(db, itemId, junction, relatedIds) {
881
+ const tbl = import_drizzle_orm2.sql.raw(quoteIdent(junction.table_name));
882
+ const left = import_drizzle_orm2.sql.raw(quoteIdent(junction.left_column));
883
+ const right = import_drizzle_orm2.sql.raw(quoteIdent(junction.right_column));
884
+ await db.execute(import_drizzle_orm2.sql`DELETE FROM ${tbl} WHERE ${left} = ${itemId}`);
885
+ for (const rid of relatedIds) {
886
+ await db.execute(import_drizzle_orm2.sql`INSERT INTO ${tbl} (${left}, ${right}) VALUES (${itemId}, ${rid})`);
887
+ }
888
+ }
889
+ function buildRelationsMap(fields, registry) {
890
+ const relations = {};
891
+ for (const field of fields) {
892
+ if (field.field_type === "image" || field.field_type === "video" || field.field_type === "file") {
893
+ if (!field.db_column) continue;
894
+ relations[field.name] = { type: "media", fk_column: field.db_column.column_name };
895
+ } else if (field.field_type === "paragraph") {
896
+ const ref = field.ui_component.ref;
897
+ const pType = ref ? registry.paragraph_types[ref] : void 0;
898
+ if (!pType) continue;
899
+ relations[field.name] = { type: "paragraph", table: pType.db.table_name };
900
+ } else if (field.field_type === "reference") {
901
+ if (!field.db_column) continue;
902
+ if (field.db_column.junction) {
903
+ const j = field.db_column.junction;
904
+ relations[field.name] = {
905
+ type: "junction",
906
+ table: j.right_table,
907
+ junction_table: j.table_name,
908
+ left_column: j.left_column,
909
+ right_column: j.right_column,
910
+ order_column: j.order_column
911
+ };
912
+ } else if (field.db_column.foreign_key) {
913
+ relations[field.name] = {
914
+ type: "reference",
915
+ table: field.db_column.foreign_key.table,
916
+ fk_column: field.db_column.column_name
917
+ };
918
+ }
919
+ }
920
+ }
921
+ return relations;
922
+ }
923
+ function groupBy(items, key) {
924
+ const result = {};
925
+ for (const item of items) {
926
+ const k = String(item[key]);
927
+ if (!result[k]) result[k] = [];
928
+ result[k].push(item);
929
+ }
930
+ return result;
931
+ }
932
+ async function resolveRelationField(db, rows, fieldName, rel, cache) {
933
+ if (rows.length === 0) return;
934
+ if (rel.type === "paragraph") {
935
+ const parentIds = rows.map((r) => r["id"]);
936
+ const inList = import_drizzle_orm2.sql.join(parentIds.map((id) => import_drizzle_orm2.sql`${id}`), import_drizzle_orm2.sql`, `);
937
+ const result = await db.execute(
938
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(quoteIdent(rel.table))} WHERE parent_id IN (${inList}) ORDER BY "order" ASC`
939
+ );
940
+ const byParent = groupBy(result.rows, "parent_id");
941
+ for (const row of rows) {
942
+ row[fieldName] = byParent[row["id"]] ?? [];
943
+ }
944
+ } else if (rel.type === "reference") {
945
+ const fkValues = rows.map((r) => r[rel.fk_column]).filter(Boolean);
946
+ if (fkValues.length === 0) {
947
+ for (const row of rows) row[fieldName] = null;
948
+ return;
949
+ }
950
+ const unique = [...new Set(fkValues)];
951
+ const uncached = unique.filter((id) => !cache.has(`${rel.table}:${id}`));
952
+ if (uncached.length > 0) {
953
+ const inList = import_drizzle_orm2.sql.join(uncached.map((id) => import_drizzle_orm2.sql`${id}`), import_drizzle_orm2.sql`, `);
954
+ const result = await db.execute(
955
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(quoteIdent(rel.table))} WHERE id IN (${inList})`
956
+ );
957
+ for (const item of result.rows) {
958
+ cache.set(`${rel.table}:${item["id"]}`, item);
959
+ }
960
+ }
961
+ for (const row of rows) {
962
+ const fkVal = row[rel.fk_column];
963
+ row[fieldName] = fkVal ? cache.get(`${rel.table}:${fkVal}`) ?? null : null;
964
+ }
965
+ } else if (rel.type === "junction") {
966
+ const parentIds = rows.map((r) => r["id"]);
967
+ const inList = import_drizzle_orm2.sql.join(parentIds.map((id) => import_drizzle_orm2.sql`${id}`), import_drizzle_orm2.sql`, `);
968
+ const orderBy = rel.order_column ? import_drizzle_orm2.sql` ORDER BY "order" ASC` : import_drizzle_orm2.sql``;
969
+ const junctionResult = await db.execute(
970
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(quoteIdent(rel.junction_table))} WHERE ${import_drizzle_orm2.sql.raw(quoteIdent(rel.left_column))} IN (${inList})${orderBy}`
971
+ );
972
+ const jRows = junctionResult.rows;
973
+ const rightIds = [...new Set(jRows.map((r) => r[rel.right_column]))];
974
+ const uncached = rightIds.filter((id) => !cache.has(`${rel.table}:${id}`));
975
+ if (uncached.length > 0) {
976
+ const rightInList = import_drizzle_orm2.sql.join(uncached.map((id) => import_drizzle_orm2.sql`${id}`), import_drizzle_orm2.sql`, `);
977
+ const entitiesResult = await db.execute(
978
+ import_drizzle_orm2.sql`SELECT * FROM ${import_drizzle_orm2.sql.raw(quoteIdent(rel.table))} WHERE id IN (${rightInList})`
979
+ );
980
+ for (const item of entitiesResult.rows) {
981
+ cache.set(`${rel.table}:${item["id"]}`, item);
982
+ }
983
+ }
984
+ const byLeft = groupBy(jRows, rel.left_column);
985
+ for (const row of rows) {
986
+ const jEntries = byLeft[row["id"]] ?? [];
987
+ row[fieldName] = jEntries.map((jr) => cache.get(`${rel.table}:${jr[rel.right_column]}`)).filter(Boolean);
988
+ }
989
+ } else if (rel.type === "media") {
990
+ const fkValues = rows.map((r) => r[rel.fk_column]).filter(Boolean);
991
+ if (fkValues.length === 0) {
992
+ for (const row of rows) row[fieldName] = null;
993
+ return;
994
+ }
995
+ const unique = [...new Set(fkValues)];
996
+ const uncached = unique.filter((id) => !cache.has(`media:${id}`));
997
+ if (uncached.length > 0) {
998
+ const inList = import_drizzle_orm2.sql.join(uncached.map((id) => import_drizzle_orm2.sql`${id}`), import_drizzle_orm2.sql`, `);
999
+ const result = await db.execute(import_drizzle_orm2.sql`SELECT * FROM "media" WHERE id IN (${inList})`);
1000
+ for (const item of result.rows) {
1001
+ cache.set(`media:${item["id"]}`, item);
1002
+ }
1003
+ }
1004
+ for (const row of rows) {
1005
+ const fkVal = row[rel.fk_column];
1006
+ row[fieldName] = fkVal ? cache.get(`media:${fkVal}`) ?? null : null;
1007
+ }
1008
+ }
1009
+ }
1010
+ async function resolveRelationBareIds(db, rows, fieldName, rel) {
1011
+ if (rows.length === 0) return;
1012
+ if (rel.type === "paragraph") {
1013
+ const parentIds = rows.map((r) => r["id"]);
1014
+ const inList = import_drizzle_orm2.sql.join(parentIds.map((id) => import_drizzle_orm2.sql`${id}`), import_drizzle_orm2.sql`, `);
1015
+ const result = await db.execute(
1016
+ import_drizzle_orm2.sql`SELECT id, parent_id FROM ${import_drizzle_orm2.sql.raw(quoteIdent(rel.table))} WHERE parent_id IN (${inList}) ORDER BY "order" ASC`
1017
+ );
1018
+ const byParent = groupBy(result.rows, "parent_id");
1019
+ for (const row of rows) {
1020
+ row[fieldName] = (byParent[row["id"]] ?? []).map((r) => r["id"]);
1021
+ }
1022
+ } else if (rel.type === "junction") {
1023
+ const parentIds = rows.map((r) => r["id"]);
1024
+ const inList = import_drizzle_orm2.sql.join(parentIds.map((id) => import_drizzle_orm2.sql`${id}`), import_drizzle_orm2.sql`, `);
1025
+ const result = await db.execute(
1026
+ import_drizzle_orm2.sql`SELECT ${import_drizzle_orm2.sql.raw(quoteIdent(rel.left_column))} AS left_id, ${import_drizzle_orm2.sql.raw(quoteIdent(rel.right_column))} AS right_id FROM ${import_drizzle_orm2.sql.raw(quoteIdent(rel.junction_table))} WHERE ${import_drizzle_orm2.sql.raw(quoteIdent(rel.left_column))} IN (${inList})`
1027
+ );
1028
+ const byLeft = groupBy(result.rows, "left_id");
1029
+ for (const row of rows) {
1030
+ row[fieldName] = (byLeft[row["id"]] ?? []).map((r) => r["right_id"]);
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ // src/routes/admin/content.ts
1036
+ function quoteIdent2(name) {
1037
+ if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`Unsafe identifier: ${name}`);
1038
+ return `"${name}"`;
1039
+ }
1040
+ async function lookupBasePathId(db, pathOrName) {
1041
+ const r = await db.execute(
1042
+ import_drizzle_orm3.sql`SELECT id FROM base_paths WHERE path = ${pathOrName} OR name = ${pathOrName} LIMIT 1`
1043
+ );
1044
+ return r.rows[0]?.id ?? null;
1045
+ }
1046
+ var SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1047
+ function isEmpty(value) {
1048
+ if (value === null || value === void 0) return true;
1049
+ if (typeof value === "string" && value.trim() === "") return true;
1050
+ if (Array.isArray(value) && value.length === 0) return true;
1051
+ return false;
1052
+ }
1053
+ function checkRequiredFields(fields, data) {
1054
+ return fields.filter((f) => f.required && isEmpty(data[f.name])).map((f) => ({ field: f.name, message: `${f.label} is required` }));
1055
+ }
1056
+ function parseListQuery(url, schemaFieldNames, relationFieldNames) {
1057
+ const searchParams = new URL(url).searchParams;
1058
+ const pagination = parsePagination(
1059
+ searchParams.get("page") ?? void 0,
1060
+ searchParams.get("per_page") ?? void 0
1061
+ );
1062
+ if (!pagination.ok) {
1063
+ return {
1064
+ ok: false,
1065
+ response: {
1066
+ code: "INVALID_PAGINATION",
1067
+ message: "page must be \u2265 1 and per_page must be between 1 and 100"
1068
+ },
1069
+ status: 400
1070
+ };
1071
+ }
1072
+ const sortBy = searchParams.get("sort_by") ?? "created_at";
1073
+ if (!SORTABLE_FIELDS.has(sortBy)) {
1074
+ return {
1075
+ ok: false,
1076
+ response: {
1077
+ code: "INVALID_SORT_FIELD",
1078
+ message: `'${sortBy}' is not sortable. Allowed: title, created_at, updated_at`
1079
+ },
1080
+ status: 400
1081
+ };
1082
+ }
1083
+ const sortOrder = searchParams.get("sort_order") ?? "asc";
1084
+ if (sortOrder !== "asc" && sortOrder !== "desc") {
1085
+ return {
1086
+ ok: false,
1087
+ response: { code: "INVALID_SORT_FIELD", message: `sort_order must be 'asc' or 'desc'` },
1088
+ status: 400
1089
+ };
1090
+ }
1091
+ const filtersResult = parseFilters(url, schemaFieldNames);
1092
+ if (!filtersResult.ok) {
1093
+ return {
1094
+ ok: false,
1095
+ response: {
1096
+ code: "INVALID_FILTER_FIELD",
1097
+ message: `Filter field '${filtersResult.invalidField}' does not exist on this schema`
1098
+ },
1099
+ status: 400
1100
+ };
1101
+ }
1102
+ const include = parseInclude(searchParams.get("include") ?? void 0);
1103
+ for (const field of include) {
1104
+ if (!relationFieldNames.has(field)) {
1105
+ return {
1106
+ ok: false,
1107
+ response: { code: "INVALID_INCLUDE_FIELD", message: `'${field}' is not a valid relation field` },
1108
+ status: 400
1109
+ };
1110
+ }
1111
+ }
1112
+ const search = (searchParams.get("search") ?? "").trim();
1113
+ return {
1114
+ ok: true,
1115
+ pagination,
1116
+ sortBy,
1117
+ sortOrder,
1118
+ filters: filtersResult.filters,
1119
+ include,
1120
+ search
1121
+ };
1122
+ }
1123
+ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePermission, db) {
1124
+ for (const [typeName, contentType] of Object.entries(registry.content_types)) {
1125
+ const basePath = `content/${typeName}`;
1126
+ const repo = repos[typeName];
1127
+ if (!repo) continue;
1128
+ const schemaFieldNames = /* @__PURE__ */ new Set([
1129
+ ...contentType.fields.map((f) => f.name),
1130
+ ...contentType.system_fields.map((f) => f.name)
1131
+ ]);
1132
+ const relationFieldNames = new Set(
1133
+ contentType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
1134
+ );
1135
+ const requiredFields = contentType.fields.filter((f) => f.required);
1136
+ const mediaFields = contentType.fields.filter(
1137
+ (f) => f.field_type === "image" || f.field_type === "video" || f.field_type === "file"
1138
+ );
1139
+ const paragraphFieldDefs = contentType.fields.filter((f) => f.db_column === null);
1140
+ const searchableColumns = [
1141
+ ...contentType.fields.filter((f) => f.field_type === "text/plain" && f.db_column !== null).map((f) => f.db_column.column_name),
1142
+ ...contentType.system_fields.some((f) => f.name === "slug") ? ["slug"] : []
1143
+ ];
1144
+ app.get(
1145
+ `/admin/api/${basePath}`,
1146
+ requirePermission("content:read"),
1147
+ async (c) => {
1148
+ const parsed = parseListQuery(c.req.url, schemaFieldNames, relationFieldNames);
1149
+ if (!parsed.ok) {
1150
+ return c.json({ ok: false, error: parsed.response }, parsed.status);
1151
+ }
1152
+ const publishedParam = c.req.query("published");
1153
+ const extraFilters = {};
1154
+ if (publishedParam === "false") extraFilters["published"] = false;
1155
+ const findOpts = {
1156
+ page: parsed.pagination.page,
1157
+ per_page: parsed.pagination.per_page,
1158
+ sort_by: parsed.sortBy,
1159
+ sort_order: parsed.sortOrder,
1160
+ filters: { ...parsed.filters, ...extraFilters },
1161
+ include: parsed.include
1162
+ };
1163
+ if (publishedParam === "true") findOpts.published_only = true;
1164
+ if (parsed.search !== "" && searchableColumns.length > 0) {
1165
+ findOpts.search = { term: parsed.search, columns: searchableColumns };
1166
+ }
1167
+ const result = await repo.findMany(findOpts);
1168
+ return c.json(result);
1169
+ }
1170
+ );
1171
+ app.get(
1172
+ `/admin/api/${basePath}/:id`,
1173
+ requirePermission("content:read"),
1174
+ async (c) => {
1175
+ const id = c.req.param("id");
1176
+ const item = await repo.findOne(id);
1177
+ if (!item) {
1178
+ return c.json(
1179
+ { ok: false, error: { code: "NOT_FOUND", message: "Not found" } },
1180
+ 404
1181
+ );
1182
+ }
1183
+ if (db) {
1184
+ const row = item;
1185
+ for (const f of contentType.fields) {
1186
+ if (f.db_column === null) {
1187
+ const comp = f.ui_component;
1188
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1189
+ const pType = registry.paragraph_types[comp.ref];
1190
+ if (!pType) continue;
1191
+ const r = await db.execute(
1192
+ import_drizzle_orm3.sql`SELECT * FROM ${import_drizzle_orm3.sql.raw(quoteIdent2(pType.db.table_name))} WHERE parent_id = ${id} AND parent_field = ${f.name} ORDER BY "order" ASC`
1193
+ );
1194
+ row[f.name] = r.rows;
1195
+ } else if (f.db_column.junction) {
1196
+ const j = f.db_column.junction;
1197
+ const r = await db.execute(
1198
+ import_drizzle_orm3.sql`SELECT ${import_drizzle_orm3.sql.raw(quoteIdent2(j.right_column))} FROM ${import_drizzle_orm3.sql.raw(quoteIdent2(j.table_name))} WHERE ${import_drizzle_orm3.sql.raw(quoteIdent2(j.left_column))} = ${id}`
1199
+ );
1200
+ row[f.name] = r.rows.map(
1201
+ (row2) => row2[j.right_column]
1202
+ );
1203
+ }
1204
+ }
1205
+ }
1206
+ return c.json({ ok: true, data: item });
1207
+ }
1208
+ );
1209
+ app.post(
1210
+ `/admin/api/${basePath}`,
1211
+ requirePermission("content:create"),
1212
+ async (c) => {
1213
+ const body = await c.req.json();
1214
+ if (contentType.only_one) {
1215
+ const existing = await repo.findMany({ page: 1, per_page: 1 });
1216
+ if (existing.meta.total > 0) {
1217
+ return c.json(
1218
+ {
1219
+ ok: false,
1220
+ error: {
1221
+ code: "SINGLETON_ALREADY_EXISTS",
1222
+ message: `Only one instance of '${contentType.label}' is allowed`
1223
+ }
1224
+ },
1225
+ 409
1226
+ );
1227
+ }
1228
+ } else {
1229
+ const slug = body["slug"];
1230
+ if (typeof slug !== "string" || slug.trim() === "") {
1231
+ return c.json(
1232
+ {
1233
+ ok: false,
1234
+ error: {
1235
+ code: "VALIDATION_ERROR",
1236
+ message: "Required fields are missing",
1237
+ details: [{ field: "slug", message: "Slug is required" }]
1238
+ }
1239
+ },
1240
+ 422
1241
+ );
1242
+ }
1243
+ if (!SLUG_PATTERN.test(slug)) {
1244
+ return c.json(
1245
+ {
1246
+ ok: false,
1247
+ error: {
1248
+ code: "INVALID_SLUG_FORMAT",
1249
+ message: "Slug must be lowercase alphanumeric with hyphens only \u2014 no leading or trailing hyphens"
1250
+ }
1251
+ },
1252
+ 422
1253
+ );
1254
+ }
1255
+ const conflict = await repo.findBySlug(slug);
1256
+ if (conflict) {
1257
+ return c.json(
1258
+ {
1259
+ ok: false,
1260
+ error: {
1261
+ code: "SLUG_CONFLICT",
1262
+ message: `Slug '${slug}' already exists for this content type`
1263
+ }
1264
+ },
1265
+ 409
1266
+ );
1267
+ }
1268
+ }
1269
+ const fieldErrors = checkRequiredFields(requiredFields, body);
1270
+ if (fieldErrors.length > 0) {
1271
+ return c.json(
1272
+ {
1273
+ ok: false,
1274
+ error: {
1275
+ code: "VALIDATION_ERROR",
1276
+ message: "Required fields are missing",
1277
+ details: fieldErrors
1278
+ }
1279
+ },
1280
+ 422
1281
+ );
1282
+ }
1283
+ if (body["published"] === true) {
1284
+ const publishDeny = await requirePermission("content:edit")(c, async () => {
1285
+ });
1286
+ if (publishDeny) return publishDeny;
1287
+ }
1288
+ const paragraphFields = contentType.fields.filter((f) => f.db_column === null);
1289
+ const junctionFields = contentType.fields.filter(
1290
+ (f) => f.db_column !== null && f.db_column.junction !== void 0
1291
+ );
1292
+ const columnFields = contentType.fields.filter(
1293
+ (f) => f.db_column !== null && f.db_column.junction === void 0
1294
+ );
1295
+ const columnData = {
1296
+ published: body["published"] ?? false
1297
+ };
1298
+ if (contentType.only_one) {
1299
+ columnData["slug"] = typeName;
1300
+ } else {
1301
+ columnData["slug"] = body["slug"];
1302
+ }
1303
+ for (const f of columnFields) {
1304
+ const val = body[f.name];
1305
+ columnData[f.db_column.column_name] = val !== void 0 ? val : null;
1306
+ }
1307
+ if (db) {
1308
+ const basePathId = await lookupBasePathId(db, contentType.default_base_path);
1309
+ if (!basePathId) {
1310
+ return c.json(
1311
+ {
1312
+ ok: false,
1313
+ error: {
1314
+ code: "BASE_PATH_NOT_FOUND",
1315
+ message: `Base path '${contentType.default_base_path}' not found \u2014 run manguito migrate`
1316
+ }
1317
+ },
1318
+ 500
1319
+ );
1320
+ }
1321
+ columnData["base_path_id"] = basePathId;
1322
+ }
1323
+ const item = await repo.create(columnData);
1324
+ const itemId = item["id"];
1325
+ const mediaDeltas = [topLevelMediaDelta(mediaFields, null, body)];
1326
+ if (db) {
1327
+ for (const f of paragraphFields) {
1328
+ const comp = f.ui_component;
1329
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1330
+ const pType = registry.paragraph_types[comp.ref];
1331
+ if (!pType) continue;
1332
+ const items = Array.isArray(body[f.name]) ? body[f.name] : [];
1333
+ mediaDeltas.push(await persistParagraphField(db, itemId, contentType.db.table_name, f.name, pType, items));
1334
+ }
1335
+ for (const f of junctionFields) {
1336
+ const junction = f.db_column.junction;
1337
+ const relatedIds = Array.isArray(body[f.name]) ? body[f.name].filter((v) => typeof v === "string") : [];
1338
+ await persistJunctionField(db, itemId, junction, relatedIds);
1339
+ }
1340
+ }
1341
+ await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1342
+ return c.json({ ok: true, data: item }, 201);
1343
+ }
1344
+ );
1345
+ app.patch(
1346
+ `/admin/api/${basePath}/:id`,
1347
+ requirePermission("content:edit"),
1348
+ async (c) => {
1349
+ const id = c.req.param("id");
1350
+ const body = await c.req.json();
1351
+ const existing = await repo.findOne(id);
1352
+ if (!existing) {
1353
+ return c.json(
1354
+ { ok: false, error: { code: "NOT_FOUND", message: "Not found" } },
1355
+ 404
1356
+ );
1357
+ }
1358
+ if (!contentType.only_one && "slug" in body) {
1359
+ const slug = body["slug"];
1360
+ if (typeof slug !== "string" || slug.trim() === "") {
1361
+ return c.json(
1362
+ {
1363
+ ok: false,
1364
+ error: {
1365
+ code: "VALIDATION_ERROR",
1366
+ message: "Required fields are missing",
1367
+ details: [{ field: "slug", message: "Slug cannot be empty" }]
1368
+ }
1369
+ },
1370
+ 422
1371
+ );
1372
+ }
1373
+ if (!SLUG_PATTERN.test(slug)) {
1374
+ return c.json(
1375
+ {
1376
+ ok: false,
1377
+ error: {
1378
+ code: "INVALID_SLUG_FORMAT",
1379
+ message: "Slug must be lowercase alphanumeric with hyphens only \u2014 no leading or trailing hyphens"
1380
+ }
1381
+ },
1382
+ 422
1383
+ );
1384
+ }
1385
+ const conflict = await repo.findBySlug(slug);
1386
+ if (conflict && conflict["id"] !== id) {
1387
+ return c.json(
1388
+ {
1389
+ ok: false,
1390
+ error: {
1391
+ code: "SLUG_CONFLICT",
1392
+ message: `Slug '${slug}' already exists for this content type`
1393
+ }
1394
+ },
1395
+ 409
1396
+ );
1397
+ }
1398
+ }
1399
+ if (body["published"] === true) {
1400
+ const publishDeny = await requirePermission("content:edit")(c, async () => {
1401
+ });
1402
+ if (publishDeny) return publishDeny;
1403
+ const merged = { ...existing, ...body };
1404
+ const fieldErrors = checkRequiredFields(requiredFields, merged);
1405
+ if (fieldErrors.length > 0) {
1406
+ return c.json(
1407
+ {
1408
+ ok: false,
1409
+ error: {
1410
+ code: "PUBLISH_VALIDATION_ERROR",
1411
+ message: "Cannot publish \u2014 required fields are missing",
1412
+ details: fieldErrors
1413
+ }
1414
+ },
1415
+ 422
1416
+ );
1417
+ }
1418
+ }
1419
+ const patchParagraphFields = contentType.fields.filter((f) => f.db_column === null);
1420
+ const patchJunctionFields = contentType.fields.filter(
1421
+ (f) => f.db_column !== null && f.db_column.junction !== void 0
1422
+ );
1423
+ const patchColumnFields = contentType.fields.filter(
1424
+ (f) => f.db_column !== null && f.db_column.junction === void 0
1425
+ );
1426
+ const patchData = {};
1427
+ if (!contentType.only_one && "slug" in body) patchData["slug"] = body["slug"];
1428
+ if ("published" in body) patchData["published"] = body["published"];
1429
+ for (const f of patchColumnFields) {
1430
+ if (f.name in body) {
1431
+ const val = body[f.name];
1432
+ patchData[f.db_column.column_name] = val !== void 0 ? val : null;
1433
+ }
1434
+ }
1435
+ const updated = await repo.update(id, patchData);
1436
+ if (!updated) {
1437
+ return c.json(
1438
+ { ok: false, error: { code: "NOT_FOUND", message: "Not found" } },
1439
+ 404
1440
+ );
1441
+ }
1442
+ const mediaDeltas = [
1443
+ topLevelMediaDelta(mediaFields, existing, body)
1444
+ ];
1445
+ if (db) {
1446
+ for (const f of patchParagraphFields) {
1447
+ const comp = f.ui_component;
1448
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1449
+ const pType = registry.paragraph_types[comp.ref];
1450
+ if (!pType) continue;
1451
+ const items = Array.isArray(body[f.name]) ? body[f.name] : [];
1452
+ mediaDeltas.push(await persistParagraphField(db, id, contentType.db.table_name, f.name, pType, items));
1453
+ }
1454
+ for (const f of patchJunctionFields) {
1455
+ const junction = f.db_column.junction;
1456
+ const relatedIds = Array.isArray(body[f.name]) ? body[f.name].filter((v) => typeof v === "string") : [];
1457
+ await persistJunctionField(db, id, junction, relatedIds);
1458
+ }
1459
+ }
1460
+ await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1461
+ return c.json({ ok: true, data: updated });
1462
+ }
1463
+ );
1464
+ app.delete(
1465
+ `/admin/api/${basePath}/:id`,
1466
+ requirePermission("content:delete"),
1467
+ async (c) => {
1468
+ const id = c.req.param("id");
1469
+ const item = await repo.findOne(id);
1470
+ if (!item) {
1471
+ return c.json(
1472
+ { ok: false, error: { code: "NOT_FOUND", message: "Not found" } },
1473
+ 404
1474
+ );
1475
+ }
1476
+ const mediaDeltas = [
1477
+ topLevelMediaDelta(mediaFields, item, null)
1478
+ ];
1479
+ if (db) {
1480
+ for (const f of paragraphFieldDefs) {
1481
+ const comp = f.ui_component;
1482
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1483
+ const pType = registry.paragraph_types[comp.ref];
1484
+ if (!pType) continue;
1485
+ mediaDeltas.push(await deleteParagraphField(db, id, f.name, pType));
1486
+ }
1487
+ }
1488
+ await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1489
+ await repo.delete(id);
1490
+ return c.json({ ok: true });
1491
+ }
1492
+ );
1493
+ }
1494
+ for (const [typeName, taxonomyType] of Object.entries(registry.taxonomy_types)) {
1495
+ const repo = repos[typeName];
1496
+ if (!repo) continue;
1497
+ const schemaFieldNames = /* @__PURE__ */ new Set([
1498
+ ...taxonomyType.fields.map((f) => f.name),
1499
+ ...taxonomyType.system_fields.map((f) => f.name)
1500
+ ]);
1501
+ const relationFieldNames = new Set(
1502
+ taxonomyType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
1503
+ );
1504
+ const requiredFields = taxonomyType.fields.filter((f) => f.required);
1505
+ const mediaFields = taxonomyType.fields.filter(
1506
+ (f) => f.field_type === "image" || f.field_type === "video" || f.field_type === "file"
1507
+ );
1508
+ const paragraphFieldDefs = taxonomyType.fields.filter((f) => f.db_column === null);
1509
+ const searchableColumns = [
1510
+ ...taxonomyType.fields.filter((f) => f.field_type === "text/plain" && f.db_column !== null).map((f) => f.db_column.column_name),
1511
+ ...taxonomyType.system_fields.some((f) => f.name === "slug") ? ["slug"] : []
1512
+ ];
1513
+ app.get(
1514
+ `/admin/api/taxonomy/${typeName}`,
1515
+ requirePermission("content:read"),
1516
+ async (c) => {
1517
+ const parsed = parseListQuery(c.req.url, schemaFieldNames, relationFieldNames);
1518
+ if (!parsed.ok) {
1519
+ return c.json({ ok: false, error: parsed.response }, parsed.status);
1520
+ }
1521
+ const publishedParam = c.req.query("published");
1522
+ const extraFilters = {};
1523
+ if (publishedParam === "false") extraFilters["published"] = false;
1524
+ const findOpts = {
1525
+ page: parsed.pagination.page,
1526
+ per_page: parsed.pagination.per_page,
1527
+ sort_by: parsed.sortBy,
1528
+ sort_order: parsed.sortOrder,
1529
+ filters: { ...parsed.filters, ...extraFilters },
1530
+ include: parsed.include
1531
+ };
1532
+ if (publishedParam === "true") findOpts.published_only = true;
1533
+ if (parsed.search !== "" && searchableColumns.length > 0) {
1534
+ findOpts.search = { term: parsed.search, columns: searchableColumns };
1535
+ }
1536
+ const result = await repo.findMany(findOpts);
1537
+ return c.json(result);
1538
+ }
1539
+ );
1540
+ app.get(
1541
+ `/admin/api/taxonomy/${typeName}/:id`,
1542
+ requirePermission("content:read"),
1543
+ async (c) => {
1544
+ const id = c.req.param("id");
1545
+ const item = await repo.findOne(id);
1546
+ if (!item) {
1547
+ return c.json(
1548
+ { ok: false, error: { code: "NOT_FOUND", message: "Taxonomy term not found" } },
1549
+ 404
1550
+ );
1551
+ }
1552
+ return c.json({ ok: true, data: item });
1553
+ }
1554
+ );
1555
+ app.post(
1556
+ `/admin/api/taxonomy/${typeName}`,
1557
+ requirePermission("content:create"),
1558
+ async (c) => {
1559
+ const body = await c.req.json();
1560
+ const fieldErrors = checkRequiredFields(requiredFields, body);
1561
+ if (fieldErrors.length > 0) {
1562
+ return c.json(
1563
+ {
1564
+ ok: false,
1565
+ error: {
1566
+ code: "VALIDATION_ERROR",
1567
+ message: "Required fields are missing",
1568
+ details: fieldErrors
1569
+ }
1570
+ },
1571
+ 422
1572
+ );
1573
+ }
1574
+ if (body["published"] === true) {
1575
+ const publishDeny = await requirePermission("content:edit")(c, async () => {
1576
+ });
1577
+ if (publishDeny) return publishDeny;
1578
+ }
1579
+ const taxColumnFields = taxonomyType.fields.filter(
1580
+ (f) => f.db_column !== null && f.db_column.junction === void 0
1581
+ );
1582
+ const taxParagraphFields = taxonomyType.fields.filter((f) => f.db_column === null);
1583
+ const taxColumnData = {
1584
+ published: body["published"] ?? false
1585
+ };
1586
+ for (const f of taxColumnFields) {
1587
+ const val = body[f.name];
1588
+ taxColumnData[f.db_column.column_name] = val !== void 0 ? val : null;
1589
+ }
1590
+ const item = await repo.create(taxColumnData);
1591
+ const taxItemId = item["id"];
1592
+ const mediaDeltas = [topLevelMediaDelta(mediaFields, null, body)];
1593
+ if (db) {
1594
+ for (const f of taxParagraphFields) {
1595
+ const comp = f.ui_component;
1596
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1597
+ const pType = registry.paragraph_types[comp.ref];
1598
+ if (!pType) continue;
1599
+ const items = Array.isArray(body[f.name]) ? body[f.name] : [];
1600
+ mediaDeltas.push(await persistParagraphField(db, taxItemId, taxonomyType.db.table_name, f.name, pType, items));
1601
+ }
1602
+ }
1603
+ await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1604
+ return c.json({ ok: true, data: item }, 201);
1605
+ }
1606
+ );
1607
+ app.patch(
1608
+ `/admin/api/taxonomy/${typeName}/:id`,
1609
+ requirePermission("content:edit"),
1610
+ async (c) => {
1611
+ const id = c.req.param("id");
1612
+ const body = await c.req.json();
1613
+ const existing = await repo.findOne(id);
1614
+ if (!existing) {
1615
+ return c.json(
1616
+ { ok: false, error: { code: "NOT_FOUND", message: "Taxonomy term not found" } },
1617
+ 404
1618
+ );
1619
+ }
1620
+ if (body["published"] === true) {
1621
+ const publishDeny = await requirePermission("content:edit")(c, async () => {
1622
+ });
1623
+ if (publishDeny) return publishDeny;
1624
+ const merged = { ...existing, ...body };
1625
+ const fieldErrors = checkRequiredFields(requiredFields, merged);
1626
+ if (fieldErrors.length > 0) {
1627
+ return c.json(
1628
+ {
1629
+ ok: false,
1630
+ error: {
1631
+ code: "PUBLISH_VALIDATION_ERROR",
1632
+ message: "Cannot publish \u2014 required fields are missing",
1633
+ details: fieldErrors
1634
+ }
1635
+ },
1636
+ 422
1637
+ );
1638
+ }
1639
+ }
1640
+ const taxPatchColumnFields = taxonomyType.fields.filter(
1641
+ (f) => f.db_column !== null && f.db_column.junction === void 0
1642
+ );
1643
+ const taxPatchParagraphFields = taxonomyType.fields.filter((f) => f.db_column === null);
1644
+ const taxPatchData = {};
1645
+ if ("published" in body) taxPatchData["published"] = body["published"];
1646
+ for (const f of taxPatchColumnFields) {
1647
+ if (f.name in body) {
1648
+ const val = body[f.name];
1649
+ taxPatchData[f.db_column.column_name] = val !== void 0 ? val : null;
1650
+ }
1651
+ }
1652
+ const updated = await repo.update(id, taxPatchData);
1653
+ if (!updated) {
1654
+ return c.json(
1655
+ { ok: false, error: { code: "NOT_FOUND", message: "Taxonomy term not found" } },
1656
+ 404
1657
+ );
1658
+ }
1659
+ const mediaDeltas = [
1660
+ topLevelMediaDelta(mediaFields, existing, body)
1661
+ ];
1662
+ if (db) {
1663
+ for (const f of taxPatchParagraphFields) {
1664
+ const comp = f.ui_component;
1665
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1666
+ const pType = registry.paragraph_types[comp.ref];
1667
+ if (!pType) continue;
1668
+ const items = Array.isArray(body[f.name]) ? body[f.name] : [];
1669
+ mediaDeltas.push(await persistParagraphField(db, id, taxonomyType.db.table_name, f.name, pType, items));
1670
+ }
1671
+ }
1672
+ await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1673
+ return c.json({ ok: true, data: updated });
1674
+ }
1675
+ );
1676
+ app.delete(
1677
+ `/admin/api/taxonomy/${typeName}/:id`,
1678
+ requirePermission("content:delete"),
1679
+ async (c) => {
1680
+ const id = c.req.param("id");
1681
+ const item = await repo.findOne(id);
1682
+ if (!item) {
1683
+ return c.json(
1684
+ { ok: false, error: { code: "NOT_FOUND", message: "Taxonomy term not found" } },
1685
+ 404
1686
+ );
1687
+ }
1688
+ const mediaDeltas = [
1689
+ topLevelMediaDelta(mediaFields, item, null)
1690
+ ];
1691
+ if (db) {
1692
+ for (const f of paragraphFieldDefs) {
1693
+ const comp = f.ui_component;
1694
+ if (comp.component !== "paragraph-embed" || !comp.ref) continue;
1695
+ const pType = registry.paragraph_types[comp.ref];
1696
+ if (!pType) continue;
1697
+ mediaDeltas.push(await deleteParagraphField(db, id, f.name, pType));
1698
+ }
1699
+ }
1700
+ await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1701
+ await repo.delete(id);
1702
+ return c.json({ ok: true });
1703
+ }
1704
+ );
1705
+ }
1706
+ app.get("/admin/api/config", (c) => {
1707
+ return c.json({
1708
+ ok: true,
1709
+ data: {
1710
+ content_types: Object.values(registry.content_types).map((ct) => ({
1711
+ name: ct.name,
1712
+ label: ct.label,
1713
+ only_one: ct.only_one,
1714
+ default_base_path: ct.default_base_path,
1715
+ fields: ct.fields,
1716
+ system_fields: ct.system_fields
1717
+ })),
1718
+ taxonomy_types: Object.values(registry.taxonomy_types).map((tt) => ({
1719
+ name: tt.name,
1720
+ label: tt.label,
1721
+ fields: tt.fields,
1722
+ system_fields: tt.system_fields
1723
+ })),
1724
+ enum_types: Object.values(registry.enum_types).map((et) => ({
1725
+ name: et.name,
1726
+ label: et.label,
1727
+ values: et.values
1728
+ }))
1729
+ }
1730
+ });
1731
+ });
1732
+ }
1733
+
1734
+ // src/routes/admin/media.ts
1735
+ var import_jwt3 = require("hono/jwt");
1736
+ var IMAGE_MIME_TYPES = /* @__PURE__ */ new Set([
1737
+ "image/jpeg",
1738
+ "image/png",
1739
+ "image/webp",
1740
+ "image/gif"
1741
+ ]);
1742
+ var VIDEO_MIME_TYPES = /* @__PURE__ */ new Set(["video/mp4", "video/webm", "video/quicktime"]);
1743
+ var FILE_MIME_TYPES = /* @__PURE__ */ new Set(["application/pdf"]);
1744
+ function authSecret() {
1745
+ const secret = process.env["AUTH_SECRET"];
1746
+ if (!secret) throw new Error("AUTH_SECRET environment variable is not set");
1747
+ return secret;
1748
+ }
1749
+ async function signPendingUpload(data, expiresAt) {
1750
+ return (0, import_jwt3.sign)({ ...data, exp: expiresAt }, authSecret());
1751
+ }
1752
+ async function verifyPendingUpload(token) {
1753
+ try {
1754
+ const p = await (0, import_jwt3.verify)(token, authSecret(), "HS256");
1755
+ return { key: p.key, folder: p.folder, mime_type: p.mime_type };
1756
+ } catch {
1757
+ return null;
1758
+ }
1759
+ }
1760
+ async function handleDirectUpload(folder, acceptedTypes, mediaRepo, storage, c, altRequired, maxFileSize) {
1761
+ if (maxFileSize !== void 0) {
1762
+ const contentLength = Number(c.req.header("content-length"));
1763
+ if (Number.isFinite(contentLength) && contentLength > maxFileSize) {
1764
+ return c.json(
1765
+ {
1766
+ ok: false,
1767
+ error: {
1768
+ code: "FILE_TOO_LARGE",
1769
+ message: `Upload exceeds the maximum size of ${maxFileSize} bytes`
1770
+ }
1771
+ },
1772
+ 413
1773
+ );
1774
+ }
1775
+ }
1776
+ let formData;
1777
+ try {
1778
+ formData = await c.req.formData();
1779
+ } catch {
1780
+ return c.json(
1781
+ { ok: false, error: { code: "VALIDATION_ERROR", message: "Expected multipart/form-data" } },
1782
+ 422
1783
+ );
1784
+ }
1785
+ const fileField = formData.get("file");
1786
+ if (!(fileField instanceof File)) {
1787
+ return c.json(
1788
+ { ok: false, error: { code: "VALIDATION_ERROR", message: "file field is required" } },
1789
+ 422
1790
+ );
1791
+ }
1792
+ if (maxFileSize !== void 0 && fileField.size > maxFileSize) {
1793
+ return c.json(
1794
+ {
1795
+ ok: false,
1796
+ error: {
1797
+ code: "FILE_TOO_LARGE",
1798
+ message: `File exceeds the maximum size of ${maxFileSize} bytes`
1799
+ }
1800
+ },
1801
+ 413
1802
+ );
1803
+ }
1804
+ const mimeType = fileField.type;
1805
+ if (!acceptedTypes.has(mimeType)) {
1806
+ return c.json(
1807
+ {
1808
+ ok: false,
1809
+ error: {
1810
+ code: "UNSUPPORTED_MIME_TYPE",
1811
+ message: `MIME type '${mimeType}' is not accepted by this endpoint`
1812
+ }
1813
+ },
1814
+ 415
1815
+ );
1816
+ }
1817
+ const altValue = formData.get("alt");
1818
+ const alt = typeof altValue === "string" && altValue.trim() !== "" ? altValue.trim() : void 0;
1819
+ if (altRequired && !alt) {
1820
+ return c.json(
1821
+ {
1822
+ ok: false,
1823
+ error: {
1824
+ code: "VALIDATION_ERROR",
1825
+ message: "alt is required for this media type",
1826
+ details: [{ field: "alt", message: "alt is required" }]
1827
+ }
1828
+ },
1829
+ 422
1830
+ );
1831
+ }
1832
+ let presigned;
1833
+ try {
1834
+ presigned = await storage.getPresignedUploadUrl({
1835
+ folder,
1836
+ filename: fileField.name,
1837
+ mime_type: mimeType
1838
+ });
1839
+ } catch {
1840
+ return c.json(
1841
+ { ok: false, error: { code: "STORAGE_ERROR", message: "Failed to generate upload URL" } },
1842
+ 502
1843
+ );
1844
+ }
1845
+ if (storage.upload) {
1846
+ try {
1847
+ const bytes = new Uint8Array(await fileField.arrayBuffer());
1848
+ await storage.upload(presigned.key, bytes, mimeType);
1849
+ } catch (err) {
1850
+ console.error("[media] storage.upload error:", err);
1851
+ return c.json(
1852
+ { ok: false, error: { code: "STORAGE_ERROR", message: "Storage upload failed" } },
1853
+ 502
1854
+ );
1855
+ }
1856
+ }
1857
+ const url = storage.getUrl(presigned.key);
1858
+ const fileSize = fileField.size;
1859
+ const item = await mediaRepo.create({
1860
+ type: folder,
1861
+ url,
1862
+ mime_type: mimeType,
1863
+ ...alt !== void 0 && { alt },
1864
+ file_size: fileSize
1865
+ });
1866
+ return c.json({ ok: true, data: item }, 201);
1867
+ }
1868
+ function registerAdminMediaRoutes(app, mediaRepo, storage, requirePermission, maxFileSize) {
1869
+ app.post(
1870
+ "/admin/api/media/image",
1871
+ requirePermission("media:create"),
1872
+ async (c) => {
1873
+ return handleDirectUpload("image", IMAGE_MIME_TYPES, mediaRepo, storage, c, false, maxFileSize);
1874
+ }
1875
+ );
1876
+ app.post(
1877
+ "/admin/api/media/video",
1878
+ requirePermission("media:create"),
1879
+ async (c) => {
1880
+ return handleDirectUpload("video", VIDEO_MIME_TYPES, mediaRepo, storage, c, true, maxFileSize);
1881
+ }
1882
+ );
1883
+ app.post(
1884
+ "/admin/api/media/file",
1885
+ requirePermission("media:create"),
1886
+ async (c) => {
1887
+ return handleDirectUpload("file", FILE_MIME_TYPES, mediaRepo, storage, c, true, maxFileSize);
1888
+ }
1889
+ );
1890
+ app.get("/admin/api/media/presigned-url", requirePermission("media:create"), async (c) => {
1891
+ const type = c.req.query("type");
1892
+ const filename = c.req.query("filename");
1893
+ const mimeType = c.req.query("mime_type");
1894
+ if (!type || !["image", "video", "file"].includes(type)) {
1895
+ return c.json(
1896
+ {
1897
+ ok: false,
1898
+ error: {
1899
+ code: "VALIDATION_ERROR",
1900
+ message: "type must be one of: image, video, file",
1901
+ details: [{ field: "type", message: "type is required" }]
1902
+ }
1903
+ },
1904
+ 422
1905
+ );
1906
+ }
1907
+ if (!filename || filename.trim() === "") {
1908
+ return c.json(
1909
+ {
1910
+ ok: false,
1911
+ error: {
1912
+ code: "VALIDATION_ERROR",
1913
+ message: "filename is required",
1914
+ details: [{ field: "filename", message: "filename is required" }]
1915
+ }
1916
+ },
1917
+ 422
1918
+ );
1919
+ }
1920
+ if (!mimeType || mimeType.trim() === "") {
1921
+ return c.json(
1922
+ {
1923
+ ok: false,
1924
+ error: {
1925
+ code: "VALIDATION_ERROR",
1926
+ message: "mime_type is required",
1927
+ details: [{ field: "mime_type", message: "mime_type is required" }]
1928
+ }
1929
+ },
1930
+ 422
1931
+ );
1932
+ }
1933
+ const accepted = type === "image" ? IMAGE_MIME_TYPES : type === "video" ? VIDEO_MIME_TYPES : FILE_MIME_TYPES;
1934
+ if (!accepted.has(mimeType)) {
1935
+ return c.json(
1936
+ {
1937
+ ok: false,
1938
+ error: {
1939
+ code: "UNSUPPORTED_MIME_TYPE",
1940
+ message: `MIME type '${mimeType}' is not accepted for ${type} uploads`
1941
+ }
1942
+ },
1943
+ 415
1944
+ );
1945
+ }
1946
+ let presigned;
1947
+ try {
1948
+ presigned = await storage.getPresignedUploadUrl({
1949
+ folder: type,
1950
+ filename,
1951
+ mime_type: mimeType
1952
+ });
1953
+ } catch {
1954
+ return c.json(
1955
+ { ok: false, error: { code: "STORAGE_ERROR", message: "Failed to generate upload URL" } },
1956
+ 502
1957
+ );
1958
+ }
1959
+ const media_id = await signPendingUpload(
1960
+ { key: presigned.key, folder: type, mime_type: mimeType },
1961
+ presigned.expires_at
1962
+ );
1963
+ return c.json({
1964
+ ok: true,
1965
+ data: {
1966
+ upload_url: presigned.upload_url,
1967
+ // Signed, self-contained token the client posts back to /confirm/:id.
1968
+ media_id,
1969
+ expires_at: presigned.expires_at,
1970
+ // Present for storages that need a multipart POST with signed fields
1971
+ // (Cloudinary); absent for a raw PUT (S3).
1972
+ ...presigned.method && { method: presigned.method },
1973
+ ...presigned.fields && { fields: presigned.fields }
1974
+ }
1975
+ });
1976
+ });
1977
+ app.post("/admin/api/media/confirm/:id", requirePermission("media:create"), async (c) => {
1978
+ const id = c.req.param("id");
1979
+ const pending = await verifyPendingUpload(id);
1980
+ if (!pending) {
1981
+ return c.json(
1982
+ {
1983
+ ok: false,
1984
+ error: {
1985
+ code: "PRESIGNED_URL_EXPIRED",
1986
+ message: "Presigned upload not found or has expired"
1987
+ }
1988
+ },
1989
+ 410
1990
+ );
1991
+ }
1992
+ const body = await c.req.json().catch(() => ({}));
1993
+ const altValue = typeof body["alt"] === "string" ? body["alt"].trim() : void 0;
1994
+ const altRequired = pending.folder === "video" || pending.folder === "file";
1995
+ if (altRequired && !altValue) {
1996
+ return c.json(
1997
+ {
1998
+ ok: false,
1999
+ error: {
2000
+ code: "VALIDATION_ERROR",
2001
+ message: "alt is required for video and file uploads",
2002
+ details: [{ field: "alt", message: "alt is required" }]
2003
+ }
2004
+ },
2005
+ 422
2006
+ );
2007
+ }
2008
+ let fileSize = 0;
2009
+ if (storage.stat) {
2010
+ const meta = await storage.stat(pending.key);
2011
+ if (!meta) {
2012
+ return c.json(
2013
+ { ok: false, error: { code: "STORAGE_ERROR", message: "Uploaded object not found" } },
2014
+ 502
2015
+ );
2016
+ }
2017
+ const accepted = pending.folder === "image" ? IMAGE_MIME_TYPES : pending.folder === "video" ? VIDEO_MIME_TYPES : FILE_MIME_TYPES;
2018
+ if (meta.content_type && !accepted.has(meta.content_type)) {
2019
+ await storage.delete(pending.key);
2020
+ return c.json(
2021
+ { ok: false, error: { code: "UNSUPPORTED_MIME_TYPE", message: `Stored object type '${meta.content_type}' is not accepted` } },
2022
+ 415
2023
+ );
2024
+ }
2025
+ if (maxFileSize !== void 0 && meta.size > maxFileSize) {
2026
+ await storage.delete(pending.key);
2027
+ return c.json(
2028
+ { ok: false, error: { code: "FILE_TOO_LARGE", message: `Uploaded file exceeds the ${maxFileSize} byte limit` } },
2029
+ 413
2030
+ );
2031
+ }
2032
+ fileSize = meta.size;
2033
+ }
2034
+ const url = storage.getUrl(pending.key);
2035
+ const item = await mediaRepo.create({
2036
+ type: pending.folder,
2037
+ url,
2038
+ mime_type: pending.mime_type,
2039
+ ...altValue !== void 0 && { alt: altValue },
2040
+ file_size: fileSize
2041
+ });
2042
+ return c.json({ ok: true, data: item }, 201);
2043
+ });
2044
+ app.patch(
2045
+ "/admin/api/media/:id",
2046
+ requirePermission("media:edit"),
2047
+ async (c) => {
2048
+ const id = c.req.param("id");
2049
+ const existing = await mediaRepo.findOne(id);
2050
+ if (!existing) {
2051
+ return c.json(
2052
+ { ok: false, error: { code: "NOT_FOUND", message: "Media item not found" } },
2053
+ 404
2054
+ );
2055
+ }
2056
+ const body = await c.req.json();
2057
+ const alt = typeof body["alt"] === "string" ? body["alt"].trim() : void 0;
2058
+ const updateData = {};
2059
+ if (alt !== void 0) updateData.alt = alt;
2060
+ const updated = await mediaRepo.update(id, updateData);
2061
+ if (!updated) {
2062
+ return c.json(
2063
+ { ok: false, error: { code: "NOT_FOUND", message: "Media item not found" } },
2064
+ 404
2065
+ );
2066
+ }
2067
+ return c.json({ ok: true, data: updated });
2068
+ }
2069
+ );
2070
+ app.delete(
2071
+ "/admin/api/media/:id",
2072
+ requirePermission("media:delete"),
2073
+ async (c) => {
2074
+ const id = c.req.param("id");
2075
+ const item = await mediaRepo.findOne(id);
2076
+ if (!item) {
2077
+ return c.json(
2078
+ { ok: false, error: { code: "NOT_FOUND", message: "Media item not found" } },
2079
+ 404
2080
+ );
2081
+ }
2082
+ if (item.reference_count > 0) {
2083
+ return c.json(
2084
+ {
2085
+ ok: false,
2086
+ error: {
2087
+ code: "MEDIA_IN_USE",
2088
+ message: "Cannot delete media that is referenced by content items"
2089
+ }
2090
+ },
2091
+ 409
2092
+ );
2093
+ }
2094
+ const pathname = new URL(item.url).pathname;
2095
+ const uploadIdx = pathname.indexOf("/upload/");
2096
+ const key = uploadIdx >= 0 ? pathname.slice(uploadIdx + "/upload/".length) : pathname.replace(/^\//, "");
2097
+ try {
2098
+ await storage.delete(key);
2099
+ } catch (err) {
2100
+ console.error("[media] storage.delete error:", err);
2101
+ return c.json(
2102
+ {
2103
+ ok: false,
2104
+ error: { code: "STORAGE_ERROR", message: "Failed to delete file from storage" }
2105
+ },
2106
+ 502
2107
+ );
2108
+ }
2109
+ await mediaRepo.delete(id);
2110
+ return c.json({ ok: true });
2111
+ }
2112
+ );
2113
+ app.get("/admin/api/media", requirePermission("media:read"), async (c) => {
2114
+ const pageStr = c.req.query("page");
2115
+ const perPageStr = c.req.query("per_page");
2116
+ const page = pageStr !== void 0 ? Number(pageStr) : 1;
2117
+ const per_page = perPageStr !== void 0 ? Number(perPageStr) : 10;
2118
+ if (!Number.isInteger(page) || page < 1 || !Number.isInteger(per_page) || per_page < 1 || per_page > 100) {
2119
+ return c.json(
2120
+ {
2121
+ ok: false,
2122
+ error: {
2123
+ code: "INVALID_PAGINATION",
2124
+ message: "page must be \u2265 1 and per_page must be between 1 and 100"
2125
+ }
2126
+ },
2127
+ 400
2128
+ );
2129
+ }
2130
+ const typeParam = c.req.query("type");
2131
+ if (typeParam !== void 0 && !["image", "video", "file"].includes(typeParam)) {
2132
+ return c.json(
2133
+ {
2134
+ ok: false,
2135
+ error: { code: "VALIDATION_ERROR", message: "type must be one of: image, video, file" }
2136
+ },
2137
+ 422
2138
+ );
2139
+ }
2140
+ const orphanedParam = c.req.query("orphaned");
2141
+ const orphaned = orphanedParam === "true" ? true : orphanedParam === "false" ? false : void 0;
2142
+ const opts = { page, per_page };
2143
+ if (typeParam !== void 0) opts.type = typeParam;
2144
+ if (orphaned !== void 0) opts.orphaned = orphaned;
2145
+ const result = await mediaRepo.findMany(opts);
2146
+ return c.json(result);
2147
+ });
2148
+ app.get("/admin/api/media/:id", requirePermission("media:read"), async (c) => {
2149
+ const id = c.req.param("id");
2150
+ const item = await mediaRepo.findOne(id);
2151
+ if (!item) {
2152
+ return c.json(
2153
+ { ok: false, error: { code: "NOT_FOUND", message: "Media item not found" } },
2154
+ 404
2155
+ );
2156
+ }
2157
+ return c.json({ ok: true, data: item });
2158
+ });
2159
+ }
2160
+
2161
+ // src/routes/admin/auth.ts
2162
+ var import_cookie3 = require("hono/cookie");
2163
+ var import_drizzle_orm4 = require("drizzle-orm");
2164
+
2165
+ // src/auth/password.ts
2166
+ var import_manguito_cms_core = require("@bobbykim/manguito-cms-core");
2167
+
2168
+ // src/routes/admin/auth.ts
2169
+ var loginAttempts = /* @__PURE__ */ new Map();
2170
+ var globalLoginAttempts = [];
2171
+ var RATE_LIMIT_MAX = 10;
2172
+ var RATE_LIMIT_WINDOW_MS = 15 * 60 * 1e3;
2173
+ var GLOBAL_LOGIN_MAX = 100;
2174
+ var DUMMY_PASSWORD_HASH = "$2a$12$y.MZw/Q4Ceg3x1y3xrRaeuTaM09zSDx1nn78guO3bqc9vgOqGda42";
2175
+ function checkRateLimit(key) {
2176
+ const now = Date.now();
2177
+ const windowStart = now - RATE_LIMIT_WINDOW_MS;
2178
+ while (globalLoginAttempts.length > 0 && globalLoginAttempts[0] <= windowStart) {
2179
+ globalLoginAttempts.shift();
2180
+ }
2181
+ if (globalLoginAttempts.length >= GLOBAL_LOGIN_MAX) {
2182
+ const retryAfterSeconds = Math.ceil((globalLoginAttempts[0] + RATE_LIMIT_WINDOW_MS - now) / 1e3);
2183
+ return { allowed: false, retryAfterSeconds };
2184
+ }
2185
+ const attempts = (loginAttempts.get(key) ?? []).filter((t) => t > windowStart);
2186
+ attempts.push(now);
2187
+ loginAttempts.set(key, attempts);
2188
+ globalLoginAttempts.push(now);
2189
+ if (attempts.length > RATE_LIMIT_MAX) {
2190
+ const oldestInWindow = attempts[0];
2191
+ const retryAfterSeconds = Math.ceil((oldestInWindow + RATE_LIMIT_WINDOW_MS - now) / 1e3);
2192
+ return { allowed: false, retryAfterSeconds };
2193
+ }
2194
+ return { allowed: true, retryAfterSeconds: 0 };
2195
+ }
2196
+ function registerAuthRoutes(app, db) {
2197
+ const invalidCredentials = {
2198
+ ok: false,
2199
+ error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password." }
2200
+ };
2201
+ app.post("/admin/api/auth/login", async (c) => {
2202
+ let body;
2203
+ try {
2204
+ body = await c.req.json();
2205
+ } catch {
2206
+ return c.json(invalidCredentials, 401);
2207
+ }
2208
+ const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : null;
2209
+ const password = typeof body.password === "string" ? body.password : null;
2210
+ if (!email || !password) {
2211
+ return c.json(invalidCredentials, 401);
2212
+ }
2213
+ const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
2214
+ const rateCheck = checkRateLimit(`${ip}:${email}`);
2215
+ if (!rateCheck.allowed) {
2216
+ c.header("Retry-After", String(rateCheck.retryAfterSeconds));
2217
+ return c.json(
2218
+ {
2219
+ ok: false,
2220
+ error: {
2221
+ code: "RATE_LIMITED",
2222
+ message: "Too many login attempts. Please try again later."
2223
+ }
2224
+ },
2225
+ 429
2226
+ );
2227
+ }
2228
+ const userResult = await db.execute(
2229
+ import_drizzle_orm4.sql`SELECT id, email, password_hash, role_id, token_version
2230
+ FROM users WHERE email = ${email} LIMIT 1`
2231
+ );
2232
+ const user = userResult.rows[0];
2233
+ if (!user) {
2234
+ await (0, import_manguito_cms_core.verifyPassword)(password, DUMMY_PASSWORD_HASH);
2235
+ return c.json(invalidCredentials, 401);
2236
+ }
2237
+ const passwordValid = await (0, import_manguito_cms_core.verifyPassword)(password, user.password_hash);
2238
+ if (!passwordValid) {
2239
+ return c.json(invalidCredentials, 401);
2240
+ }
2241
+ const roleResult = await db.execute(
2242
+ import_drizzle_orm4.sql`SELECT r.name AS name, u.must_change_password
2243
+ FROM roles r
2244
+ JOIN users u ON u.role_id = r.id
2245
+ WHERE r.id = ${user.role_id} AND u.id = ${user.id}
2246
+ LIMIT 1`
2247
+ );
2248
+ const roleRow = roleResult.rows[0];
2249
+ if (!roleRow) {
2250
+ return c.json(
2251
+ { ok: false, error: { code: "INVALID_ROLE", message: "User role not found." } },
2252
+ 500
2253
+ );
2254
+ }
2255
+ const [authToken, refreshToken] = await Promise.all([
2256
+ signToken(
2257
+ { user_id: user.id, role: roleRow.name, token_version: user.token_version },
2258
+ AUTH_TOKEN_TTL
2259
+ ),
2260
+ signToken(
2261
+ { user_id: user.id, role: roleRow.name, token_version: user.token_version },
2262
+ REFRESH_TOKEN_TTL
2263
+ )
2264
+ ]);
2265
+ setAuthCookie(c, "auth_token", authToken, { path: "/" });
2266
+ setAuthCookie(c, "refresh_token", refreshToken, { path: "/admin/api/auth" });
2267
+ return c.json({
2268
+ ok: true,
2269
+ data: {
2270
+ id: user.id,
2271
+ email: user.email,
2272
+ role: roleRow.name,
2273
+ must_change_password: roleRow.must_change_password
2274
+ }
2275
+ });
2276
+ });
2277
+ app.post("/admin/api/auth/refresh", async (c) => {
2278
+ const token = (0, import_cookie3.getCookie)(c, "refresh_token");
2279
+ if (!token) {
2280
+ return c.json(
2281
+ { ok: false, error: { code: "UNAUTHORIZED", message: "Authentication required." } },
2282
+ 401
2283
+ );
2284
+ }
2285
+ let payload;
2286
+ try {
2287
+ payload = await verifyToken(token);
2288
+ } catch (err) {
2289
+ const code = err.code;
2290
+ if (code === "TOKEN_EXPIRED") {
2291
+ return c.json(
2292
+ { ok: false, error: { code: "TOKEN_EXPIRED", message: "Token has expired." } },
2293
+ 401
2294
+ );
2295
+ }
2296
+ return c.json(
2297
+ { ok: false, error: { code: "TOKEN_INVALID", message: "Token signature is invalid." } },
2298
+ 401
2299
+ );
2300
+ }
2301
+ const rowResult = await db.execute(
2302
+ import_drizzle_orm4.sql`SELECT u.token_version, r.name AS role
2303
+ FROM users u
2304
+ JOIN roles r ON r.id = u.role_id
2305
+ WHERE u.id = ${payload.user_id}
2306
+ LIMIT 1`
2307
+ );
2308
+ const row = rowResult.rows[0];
2309
+ if (!row || payload.token_version !== row.token_version) {
2310
+ return c.json(
2311
+ { ok: false, error: { code: "TOKEN_INVALID", message: "Token has been invalidated." } },
2312
+ 401
2313
+ );
2314
+ }
2315
+ const newAuthToken = await signToken(
2316
+ { user_id: payload.user_id, role: row.role, token_version: row.token_version },
2317
+ AUTH_TOKEN_TTL
2318
+ );
2319
+ setAuthCookie(c, "auth_token", newAuthToken, { path: "/" });
2320
+ return c.json({ ok: true });
2321
+ });
2322
+ const authMiddleware = createAuthMiddleware(db);
2323
+ app.post("/admin/api/auth/logout", authMiddleware, async (c) => {
2324
+ const user = c.get("user");
2325
+ await db.execute(
2326
+ import_drizzle_orm4.sql`UPDATE users SET token_version = token_version + 1 WHERE id = ${user.id}`
2327
+ );
2328
+ clearAuthCookie(c, "auth_token");
2329
+ clearAuthCookie(c, "refresh_token", "/admin/api/auth");
2330
+ return c.json({ ok: true });
2331
+ });
2332
+ }
2333
+
2334
+ // src/routes/admin/users.ts
2335
+ var import_drizzle_orm5 = require("drizzle-orm");
2336
+ var import_node_crypto2 = require("crypto");
2337
+ function getActingUser(c) {
2338
+ return c.get("user");
2339
+ }
2340
+ function generateTempPassword() {
2341
+ return (0, import_node_crypto2.randomBytes)(8).toString("base64url");
2342
+ }
2343
+ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2344
+ app.post("/admin/api/users/change-password", async (c) => {
2345
+ const actingUser = getActingUser(c);
2346
+ let body;
2347
+ try {
2348
+ body = await c.req.json();
2349
+ } catch {
2350
+ return c.json(
2351
+ { ok: false, error: { code: "INVALID_CREDENTIALS", message: "Invalid credentials." } },
2352
+ 401
2353
+ );
2354
+ }
2355
+ const currentPassword = typeof body.current_password === "string" ? body.current_password : null;
2356
+ const newPassword = typeof body.new_password === "string" ? body.new_password : null;
2357
+ if (!currentPassword || !newPassword) {
2358
+ return c.json(
2359
+ {
2360
+ ok: false,
2361
+ error: {
2362
+ code: "VALIDATION_ERROR",
2363
+ message: "current_password and new_password are required."
2364
+ }
2365
+ },
2366
+ 422
2367
+ );
2368
+ }
2369
+ const hashResult = await db.execute(
2370
+ import_drizzle_orm5.sql`SELECT password_hash FROM users WHERE id = ${actingUser.id} LIMIT 1`
2371
+ );
2372
+ const hashRow = hashResult.rows[0];
2373
+ if (!hashRow || !await (0, import_manguito_cms_core.verifyPassword)(currentPassword, hashRow.password_hash)) {
2374
+ return c.json(
2375
+ { ok: false, error: { code: "INVALID_CREDENTIALS", message: "Invalid credentials." } },
2376
+ 401
2377
+ );
2378
+ }
2379
+ const newHash = await (0, import_manguito_cms_core.hashPassword)(newPassword);
2380
+ await db.execute(
2381
+ import_drizzle_orm5.sql`UPDATE users
2382
+ SET password_hash = ${newHash},
2383
+ must_change_password = false,
2384
+ token_version = token_version + 1,
2385
+ updated_at = NOW()
2386
+ WHERE id = ${actingUser.id}`
2387
+ );
2388
+ return c.json({ ok: true });
2389
+ });
2390
+ app.get("/admin/api/users", requirePermission("users:read"), async (c) => {
2391
+ const result = await db.execute(
2392
+ import_drizzle_orm5.sql`SELECT u.id, u.email, r.name AS role, u.must_change_password,
2393
+ u.created_at, u.updated_at
2394
+ FROM users u
2395
+ JOIN roles r ON r.id = u.role_id
2396
+ ORDER BY u.created_at ASC`
2397
+ );
2398
+ return c.json({ ok: true, data: result.rows });
2399
+ });
2400
+ app.get("/admin/api/users/:id", requirePermission("users:read"), async (c) => {
2401
+ const id = c.req.param("id");
2402
+ const result = await db.execute(
2403
+ import_drizzle_orm5.sql`SELECT u.id, u.email, r.name AS role, u.must_change_password,
2404
+ u.created_at, u.updated_at
2405
+ FROM users u
2406
+ JOIN roles r ON r.id = u.role_id
2407
+ WHERE u.id = ${id}
2408
+ LIMIT 1`
2409
+ );
2410
+ const row = result.rows[0];
2411
+ if (!row) {
2412
+ return c.json(
2413
+ { ok: false, error: { code: "NOT_FOUND", message: "User not found." } },
2414
+ 404
2415
+ );
2416
+ }
2417
+ return c.json({ ok: true, data: row });
2418
+ });
2419
+ app.post(
2420
+ "/admin/api/users",
2421
+ requirePermission("users:create"),
2422
+ requireHierarchy(),
2423
+ async (c) => {
2424
+ let body;
2425
+ try {
2426
+ body = await c.req.json();
2427
+ } catch {
2428
+ return c.json(
2429
+ { ok: false, error: { code: "VALIDATION_ERROR", message: "Request body is required." } },
2430
+ 422
2431
+ );
2432
+ }
2433
+ const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : null;
2434
+ const roleName = typeof body.role === "string" ? body.role : null;
2435
+ if (!email) {
2436
+ return c.json(
2437
+ { ok: false, error: { code: "VALIDATION_ERROR", message: "email is required." } },
2438
+ 422
2439
+ );
2440
+ }
2441
+ if (!roleName) {
2442
+ return c.json(
2443
+ { ok: false, error: { code: "INVALID_ROLE", message: "role is required." } },
2444
+ 400
2445
+ );
2446
+ }
2447
+ const roleResult = await db.execute(
2448
+ import_drizzle_orm5.sql`SELECT id FROM roles WHERE name = ${roleName} LIMIT 1`
2449
+ );
2450
+ const roleRow = roleResult.rows[0];
2451
+ if (!roleRow) {
2452
+ return c.json(
2453
+ {
2454
+ ok: false,
2455
+ error: { code: "INVALID_ROLE", message: `Role "${roleName}" does not exist.` }
2456
+ },
2457
+ 400
2458
+ );
2459
+ }
2460
+ const tempPassword = generateTempPassword();
2461
+ const passwordHash = await (0, import_manguito_cms_core.hashPassword)(tempPassword);
2462
+ const insertResult = await db.execute(
2463
+ import_drizzle_orm5.sql`INSERT INTO users
2464
+ (id, email, password_hash, role_id, must_change_password, token_version,
2465
+ created_at, updated_at)
2466
+ VALUES
2467
+ (gen_random_uuid(), ${email}, ${passwordHash}, ${roleRow.id},
2468
+ true, 0, NOW(), NOW())
2469
+ RETURNING id, email, must_change_password, created_at, updated_at`
2470
+ );
2471
+ const newUser = insertResult.rows[0];
2472
+ if (!newUser) {
2473
+ return c.json(
2474
+ { ok: false, error: { code: "INTERNAL_ERROR", message: "Failed to create user." } },
2475
+ 500
2476
+ );
2477
+ }
2478
+ return c.json(
2479
+ {
2480
+ ok: true,
2481
+ data: {
2482
+ id: newUser.id,
2483
+ email: newUser.email,
2484
+ role: roleName,
2485
+ must_change_password: newUser.must_change_password,
2486
+ temporary_password: tempPassword,
2487
+ created_at: newUser.created_at,
2488
+ updated_at: newUser.updated_at
2489
+ }
2490
+ },
2491
+ 201
2492
+ );
2493
+ }
2494
+ );
2495
+ app.patch(
2496
+ "/admin/api/users/:id",
2497
+ requirePermission("users:edit"),
2498
+ requireHierarchy(),
2499
+ async (c) => {
2500
+ const id = c.req.param("id");
2501
+ const actingUser = getActingUser(c);
2502
+ let body;
2503
+ try {
2504
+ body = await c.req.json();
2505
+ } catch {
2506
+ body = {};
2507
+ }
2508
+ if (actingUser.id === id && body.role !== void 0) {
2509
+ return c.json(
2510
+ {
2511
+ ok: false,
2512
+ error: { code: "INSUFFICIENT_PRIVILEGE", message: "You cannot change your own role." }
2513
+ },
2514
+ 403
2515
+ );
2516
+ }
2517
+ const existingResult = await db.execute(
2518
+ import_drizzle_orm5.sql`SELECT id, email, role_id FROM users WHERE id = ${id} LIMIT 1`
2519
+ );
2520
+ const existing = existingResult.rows[0];
2521
+ if (!existing) {
2522
+ return c.json(
2523
+ { ok: false, error: { code: "NOT_FOUND", message: "User not found." } },
2524
+ 404
2525
+ );
2526
+ }
2527
+ const newEmail = typeof body.email === "string" ? body.email.trim().toLowerCase() : existing.email;
2528
+ let newRoleId = existing.role_id;
2529
+ let changeRole = false;
2530
+ if (typeof body.role === "string" && body.role) {
2531
+ const roleResult = await db.execute(
2532
+ import_drizzle_orm5.sql`SELECT id FROM roles WHERE name = ${body.role} LIMIT 1`
2533
+ );
2534
+ const roleRow = roleResult.rows[0];
2535
+ if (!roleRow) {
2536
+ return c.json(
2537
+ {
2538
+ ok: false,
2539
+ error: {
2540
+ code: "INVALID_ROLE",
2541
+ message: `Role "${body.role}" does not exist.`
2542
+ }
2543
+ },
2544
+ 400
2545
+ );
2546
+ }
2547
+ newRoleId = roleRow.id;
2548
+ changeRole = true;
2549
+ }
2550
+ if (changeRole) {
2551
+ await db.execute(
2552
+ import_drizzle_orm5.sql`UPDATE users
2553
+ SET email = ${newEmail},
2554
+ role_id = ${newRoleId},
2555
+ token_version = token_version + 1,
2556
+ updated_at = NOW()
2557
+ WHERE id = ${id}`
2558
+ );
2559
+ } else {
2560
+ await db.execute(
2561
+ import_drizzle_orm5.sql`UPDATE users
2562
+ SET email = ${newEmail},
2563
+ role_id = ${newRoleId},
2564
+ updated_at = NOW()
2565
+ WHERE id = ${id}`
2566
+ );
2567
+ }
2568
+ const updatedResult = await db.execute(
2569
+ import_drizzle_orm5.sql`SELECT u.id, u.email, r.name AS role, u.must_change_password,
2570
+ u.created_at, u.updated_at
2571
+ FROM users u
2572
+ JOIN roles r ON r.id = u.role_id
2573
+ WHERE u.id = ${id}
2574
+ LIMIT 1`
2575
+ );
2576
+ const updated = updatedResult.rows[0];
2577
+ if (!updated) {
2578
+ return c.json(
2579
+ { ok: false, error: { code: "NOT_FOUND", message: "User not found." } },
2580
+ 404
2581
+ );
2582
+ }
2583
+ return c.json({ ok: true, data: updated });
2584
+ }
2585
+ );
2586
+ app.delete(
2587
+ "/admin/api/users/:id",
2588
+ requirePermission("users:delete"),
2589
+ requireHierarchy(),
2590
+ async (c) => {
2591
+ const id = c.req.param("id");
2592
+ const actingUser = getActingUser(c);
2593
+ if (actingUser.id === id) {
2594
+ return c.json(
2595
+ {
2596
+ ok: false,
2597
+ error: {
2598
+ code: "INSUFFICIENT_PRIVILEGE",
2599
+ message: "You cannot delete your own account."
2600
+ }
2601
+ },
2602
+ 403
2603
+ );
2604
+ }
2605
+ const result = await db.execute(
2606
+ import_drizzle_orm5.sql`DELETE FROM users WHERE id = ${id} RETURNING id`
2607
+ );
2608
+ if (result.rows.length === 0) {
2609
+ return c.json(
2610
+ { ok: false, error: { code: "NOT_FOUND", message: "User not found." } },
2611
+ 404
2612
+ );
2613
+ }
2614
+ return c.json({ ok: true });
2615
+ }
2616
+ );
2617
+ app.post(
2618
+ "/admin/api/users/:id/reset-password",
2619
+ requirePermission("users:edit"),
2620
+ requireHierarchy(),
2621
+ async (c) => {
2622
+ const id = c.req.param("id");
2623
+ const actingUser = getActingUser(c);
2624
+ if (actingUser.id === id) {
2625
+ return c.json(
2626
+ {
2627
+ ok: false,
2628
+ error: {
2629
+ code: "INSUFFICIENT_PRIVILEGE",
2630
+ message: "You cannot reset your own password via this endpoint."
2631
+ }
2632
+ },
2633
+ 403
2634
+ );
2635
+ }
2636
+ const existingResult = await db.execute(
2637
+ import_drizzle_orm5.sql`SELECT id FROM users WHERE id = ${id} LIMIT 1`
2638
+ );
2639
+ const existing = existingResult.rows[0];
2640
+ if (!existing) {
2641
+ return c.json(
2642
+ { ok: false, error: { code: "NOT_FOUND", message: "User not found." } },
2643
+ 404
2644
+ );
2645
+ }
2646
+ const tempPassword = generateTempPassword();
2647
+ const passwordHash = await (0, import_manguito_cms_core.hashPassword)(tempPassword);
2648
+ await db.execute(
2649
+ import_drizzle_orm5.sql`UPDATE users
2650
+ SET password_hash = ${passwordHash},
2651
+ must_change_password = true,
2652
+ token_version = token_version + 1,
2653
+ updated_at = NOW()
2654
+ WHERE id = ${id}`
2655
+ );
2656
+ return c.json({ ok: true, data: { temporary_password: tempPassword } });
2657
+ }
2658
+ );
2659
+ }
2660
+
2661
+ // src/routes/admin/config.ts
2662
+ var import_drizzle_orm6 = require("drizzle-orm");
2663
+ var API_VERSION = (() => {
2664
+ try {
2665
+ return "0.1.0";
2666
+ } catch {
2667
+ return "0.0.0";
2668
+ }
2669
+ })();
2670
+ function registerConfigRoute(app, config, registry, db) {
2671
+ app.get("/admin/api/config", async (c) => {
2672
+ const actingUser = c.get("user");
2673
+ const actingRole = registry[actingUser.role];
2674
+ const actingLevel = actingRole?.hierarchy_level ?? Infinity;
2675
+ const roles = Object.values(registry).filter((r) => r.name !== "admin" && r.hierarchy_level > actingLevel).sort((a, b) => a.hierarchy_level - b.hierarchy_level).map((r) => ({ name: r.name, label: r.label, hierarchy_level: r.hierarchy_level }));
2676
+ const all_roles = Object.values(registry).sort((a, b) => a.hierarchy_level - b.hierarchy_level).map((r) => ({
2677
+ name: r.name,
2678
+ label: r.label,
2679
+ hierarchy_level: r.hierarchy_level,
2680
+ is_system: r.is_system,
2681
+ permissions: r.permissions
2682
+ }));
2683
+ const userResult = await db.execute(
2684
+ import_drizzle_orm6.sql`SELECT email, must_change_password FROM users WHERE id = ${actingUser.id} LIMIT 1`
2685
+ );
2686
+ const userRow = userResult.rows[0];
2687
+ return c.json({
2688
+ ok: true,
2689
+ data: {
2690
+ cms_name: config.name ?? "Manguito CMS",
2691
+ version: API_VERSION,
2692
+ roles,
2693
+ all_roles,
2694
+ user: {
2695
+ id: actingUser.id,
2696
+ email: userRow?.email ?? "",
2697
+ role: actingUser.role,
2698
+ must_change_password: userRow?.must_change_password ?? false
2699
+ },
2700
+ media: {
2701
+ max_file_size: config.maxFileSize ?? null,
2702
+ presigned_uploads: config.presignedUploads ?? false
2703
+ }
2704
+ }
2705
+ });
2706
+ });
2707
+ }
2708
+
2709
+ // src/routes/admin/schema.ts
2710
+ var import_drizzle_orm7 = require("drizzle-orm");
2711
+ function registerSchemaRoute(app, registry, db) {
2712
+ app.get("/admin/api/schema", (c) => {
2713
+ const content_types = Object.values(registry.content_types).map((ct) => ({
2714
+ name: ct.name,
2715
+ label: ct.label,
2716
+ only_one: ct.only_one,
2717
+ ui: ct.ui,
2718
+ system_fields: ct.system_fields,
2719
+ fields: ct.fields
2720
+ }));
2721
+ const taxonomy_types = Object.values(registry.taxonomy_types).map((tt) => ({
2722
+ name: tt.name,
2723
+ label: tt.label,
2724
+ system_fields: tt.system_fields,
2725
+ fields: tt.fields
2726
+ }));
2727
+ const paragraph_types = Object.values(registry.paragraph_types).map((pt) => ({
2728
+ name: pt.name,
2729
+ label: pt.label,
2730
+ system_fields: pt.system_fields,
2731
+ fields: pt.fields
2732
+ }));
2733
+ const enum_types = Object.values(registry.enum_types).map((et) => ({
2734
+ name: et.name,
2735
+ label: et.label,
2736
+ values: et.values
2737
+ }));
2738
+ return c.json({
2739
+ ok: true,
2740
+ data: { content_types, taxonomy_types, paragraph_types, enum_types }
2741
+ });
2742
+ });
2743
+ app.get("/admin/api/content", async (c) => {
2744
+ const data = await Promise.all(
2745
+ Object.values(registry.content_types).map(async (ct) => {
2746
+ const result = await db.execute(
2747
+ import_drizzle_orm7.sql`SELECT COUNT(*)::int AS count FROM ${import_drizzle_orm7.sql.raw(`"${ct.db.table_name}"`)}`
2748
+ );
2749
+ const count = Number(result.rows[0].count);
2750
+ return { name: ct.name, label: ct.label, only_one: ct.only_one, count };
2751
+ })
2752
+ );
2753
+ return c.json({ ok: true, data });
2754
+ });
2755
+ app.get("/admin/api/taxonomy", async (c) => {
2756
+ const data = await Promise.all(
2757
+ Object.values(registry.taxonomy_types).map(async (tt) => {
2758
+ const result = await db.execute(
2759
+ import_drizzle_orm7.sql`SELECT COUNT(*)::int AS count FROM ${import_drizzle_orm7.sql.raw(`"${tt.db.table_name}"`)}`
2760
+ );
2761
+ const count = Number(result.rows[0].count);
2762
+ return { name: tt.name, label: tt.label, count };
2763
+ })
2764
+ );
2765
+ return c.json({ ok: true, data });
2766
+ });
2767
+ }
2768
+
2769
+ // src/repositories/content.ts
2770
+ var import_node_crypto3 = __toESM(require("crypto"));
2771
+ var import_drizzle_orm8 = require("drizzle-orm");
2772
+ var SORTABLE_FIELDS2 = /* @__PURE__ */ new Set(["title", "created_at", "updated_at"]);
2773
+ function codeError(code, message) {
2774
+ const err = new Error(message);
2775
+ err.code = code;
2776
+ return err;
2777
+ }
2778
+ function quoteIdent3(name) {
2779
+ if (!/^[a-z][a-z0-9_-]*$/.test(name)) {
2780
+ throw new Error(`Unsafe SQL identifier: ${name}`);
2781
+ }
2782
+ return `"${name.replace(/"/g, '""')}"`;
2783
+ }
2784
+ function quoteField(name) {
2785
+ if (!/^[a-z][a-z0-9_]*$/.test(name)) {
2786
+ throw codeError("INVALID_FILTER_FIELD", `Invalid filter field name: ${name}`);
2787
+ }
2788
+ return `"${name}"`;
2789
+ }
2790
+ function buildConditions(published_only, filters, search) {
2791
+ const conds = [];
2792
+ if (published_only) {
2793
+ conds.push(import_drizzle_orm8.sql`published = true`);
2794
+ }
2795
+ if (search && search.term.trim() !== "" && search.columns.length > 0) {
2796
+ const pattern = `%${search.term.trim()}%`;
2797
+ const orConds = search.columns.map((col) => import_drizzle_orm8.sql`${import_drizzle_orm8.sql.raw(quoteField(col))} ILIKE ${pattern}`);
2798
+ conds.push(import_drizzle_orm8.sql`(${import_drizzle_orm8.sql.join(orConds, import_drizzle_orm8.sql` OR `)})`);
2799
+ }
2800
+ if (filters) {
2801
+ for (const [field, value] of Object.entries(filters)) {
2802
+ const col = import_drizzle_orm8.sql.raw(quoteField(field));
2803
+ if (Array.isArray(value)) {
2804
+ if (value.length === 0) continue;
2805
+ const inList = import_drizzle_orm8.sql.join(
2806
+ value.map((v) => import_drizzle_orm8.sql`${v}`),
2807
+ import_drizzle_orm8.sql`, `
2808
+ );
2809
+ conds.push(import_drizzle_orm8.sql`${col} IN (${inList})`);
2810
+ } else if (typeof value === "object" && value !== null) {
2811
+ const op = value;
2812
+ if (op.gt !== void 0) conds.push(import_drizzle_orm8.sql`${col} > ${op.gt}`);
2813
+ if (op.gte !== void 0) conds.push(import_drizzle_orm8.sql`${col} >= ${op.gte}`);
2814
+ if (op.lt !== void 0) conds.push(import_drizzle_orm8.sql`${col} < ${op.lt}`);
2815
+ if (op.lte !== void 0) conds.push(import_drizzle_orm8.sql`${col} <= ${op.lte}`);
2816
+ } else {
2817
+ conds.push(import_drizzle_orm8.sql`${col} = ${value}`);
2818
+ }
2819
+ }
2820
+ }
2821
+ return conds;
2822
+ }
2823
+ function whereFragment(conds) {
2824
+ return conds.length > 0 ? import_drizzle_orm8.sql` WHERE ${import_drizzle_orm8.sql.join(conds, import_drizzle_orm8.sql` AND `)}` : import_drizzle_orm8.sql``;
2825
+ }
2826
+ function createDrizzleContentRepository(db, tableName, options = {}) {
2827
+ const { relations = {} } = options;
2828
+ function tableRaw() {
2829
+ return import_drizzle_orm8.sql.raw(quoteIdent3(tableName));
2830
+ }
2831
+ async function resolveRows(rows, include, cache) {
2832
+ if (rows.length === 0) return rows;
2833
+ for (const fieldName of include) {
2834
+ if (!relations[fieldName]) {
2835
+ throw codeError("INVALID_INCLUDE_FIELD", `'${fieldName}' is not a valid relation field`);
2836
+ }
2837
+ }
2838
+ for (const [fieldName, rel] of Object.entries(relations)) {
2839
+ if (rel.type === "media") {
2840
+ await resolveRelationField(db, rows, fieldName, rel, cache);
2841
+ } else if (!include.includes(fieldName)) {
2842
+ await resolveRelationBareIds(db, rows, fieldName, rel);
2843
+ }
2844
+ }
2845
+ for (const fieldName of include) {
2846
+ const rel = relations[fieldName];
2847
+ if (rel.type !== "media") {
2848
+ await resolveRelationField(db, rows, fieldName, rel, cache);
2849
+ }
2850
+ }
2851
+ return rows;
2852
+ }
2853
+ return {
2854
+ async findMany(opts) {
2855
+ const {
2856
+ page = 1,
2857
+ per_page = 10,
2858
+ include = [],
2859
+ published_only,
2860
+ filters,
2861
+ sort_by = "created_at",
2862
+ sort_order = "asc",
2863
+ search
2864
+ } = opts;
2865
+ if (!SORTABLE_FIELDS2.has(sort_by)) {
2866
+ throw codeError("INVALID_SORT_FIELD", `'${sort_by}' is not sortable. Allowed: title, created_at, updated_at`);
2867
+ }
2868
+ const offset = (page - 1) * per_page;
2869
+ const conds = buildConditions(published_only, filters, search);
2870
+ const where = whereFragment(conds);
2871
+ const sortCol = import_drizzle_orm8.sql.raw(quoteIdent3(sort_by));
2872
+ const sortDir = import_drizzle_orm8.sql.raw(sort_order === "desc" ? "DESC" : "ASC");
2873
+ const countResult = await db.execute(
2874
+ import_drizzle_orm8.sql`SELECT COUNT(*) AS total FROM ${tableRaw()}${where}`
2875
+ );
2876
+ const total = Number(countResult.rows[0]?.["total"] ?? 0);
2877
+ const dataResult = await db.execute(
2878
+ import_drizzle_orm8.sql`SELECT * FROM ${tableRaw()}${where} ORDER BY ${sortCol} ${sortDir} LIMIT ${per_page} OFFSET ${offset}`
2879
+ );
2880
+ const rows = dataResult.rows;
2881
+ const cache = /* @__PURE__ */ new Map();
2882
+ await resolveRows(rows, include, cache);
2883
+ const total_pages = Math.ceil(total / per_page);
2884
+ return {
2885
+ ok: true,
2886
+ data: rows,
2887
+ meta: {
2888
+ total,
2889
+ page,
2890
+ per_page,
2891
+ total_pages,
2892
+ has_next: page < total_pages,
2893
+ has_prev: page > 1
2894
+ }
2895
+ };
2896
+ },
2897
+ async findOne(id, include = []) {
2898
+ const result = await db.execute(
2899
+ import_drizzle_orm8.sql`SELECT * FROM ${tableRaw()} WHERE id = ${id} LIMIT 1`
2900
+ );
2901
+ if (result.rows.length === 0) return null;
2902
+ const rows = result.rows;
2903
+ const cache = /* @__PURE__ */ new Map();
2904
+ await resolveRows(rows, include, cache);
2905
+ return rows[0];
2906
+ },
2907
+ async findBySlug(slug, include = []) {
2908
+ const result = await db.execute(
2909
+ import_drizzle_orm8.sql`SELECT * FROM ${tableRaw()} WHERE slug = ${slug} LIMIT 1`
2910
+ );
2911
+ if (result.rows.length === 0) return null;
2912
+ const rows = result.rows;
2913
+ const cache = /* @__PURE__ */ new Map();
2914
+ await resolveRows(rows, include, cache);
2915
+ return rows[0];
2916
+ },
2917
+ async create(data) {
2918
+ const record = {
2919
+ ...data,
2920
+ id: import_node_crypto3.default.randomUUID(),
2921
+ created_at: /* @__PURE__ */ new Date(),
2922
+ updated_at: /* @__PURE__ */ new Date()
2923
+ };
2924
+ const entries = Object.entries(record).filter(([, v]) => v !== void 0);
2925
+ const colsSql = import_drizzle_orm8.sql.join(entries.map(([k]) => import_drizzle_orm8.sql.raw(quoteIdent3(k))), import_drizzle_orm8.sql`, `);
2926
+ const valsSql = import_drizzle_orm8.sql.join(entries.map(([, v]) => import_drizzle_orm8.sql`${v}`), import_drizzle_orm8.sql`, `);
2927
+ const result = await db.execute(
2928
+ import_drizzle_orm8.sql`INSERT INTO ${tableRaw()} (${colsSql}) VALUES (${valsSql}) RETURNING *`
2929
+ );
2930
+ return result.rows[0];
2931
+ },
2932
+ async update(id, data) {
2933
+ const record = {
2934
+ ...data,
2935
+ updated_at: /* @__PURE__ */ new Date()
2936
+ };
2937
+ const entries = Object.entries(record).filter(([k, v]) => k !== "id" && v !== void 0);
2938
+ if (entries.length === 0) {
2939
+ return this.findOne(id);
2940
+ }
2941
+ const setClauses = import_drizzle_orm8.sql.join(
2942
+ entries.map(([k, v]) => import_drizzle_orm8.sql`${import_drizzle_orm8.sql.raw(quoteIdent3(k))} = ${v}`),
2943
+ import_drizzle_orm8.sql`, `
2944
+ );
2945
+ const result = await db.execute(
2946
+ import_drizzle_orm8.sql`UPDATE ${tableRaw()} SET ${setClauses} WHERE id = ${id} RETURNING *`
2947
+ );
2948
+ if (result.rows.length === 0) return null;
2949
+ return result.rows[0];
2950
+ },
2951
+ async delete(id) {
2952
+ await db.execute(import_drizzle_orm8.sql`DELETE FROM ${tableRaw()} WHERE id = ${id}`);
2953
+ },
2954
+ async findAll(opts = {}) {
2955
+ const { include = [], published_only } = opts;
2956
+ const conds = buildConditions(published_only, void 0, void 0);
2957
+ const where = whereFragment(conds);
2958
+ const result = await db.execute(
2959
+ import_drizzle_orm8.sql`SELECT * FROM ${tableRaw()}${where} ORDER BY created_at ASC`
2960
+ );
2961
+ const rows = result.rows;
2962
+ const cache = /* @__PURE__ */ new Map();
2963
+ await resolveRows(rows, include, cache);
2964
+ return rows;
2965
+ }
2966
+ };
2967
+ }
2968
+
2969
+ // src/repositories/media.ts
2970
+ var import_node_crypto4 = __toESM(require("crypto"));
2971
+ var import_drizzle_orm9 = require("drizzle-orm");
2972
+ function createMediaRepository(db) {
2973
+ const TABLE = import_drizzle_orm9.sql.raw('"media"');
2974
+ return {
2975
+ async findMany(opts = {}) {
2976
+ const { page = 1, per_page = 10, type, orphaned } = opts;
2977
+ const offset = (page - 1) * per_page;
2978
+ const conds = [];
2979
+ if (type) conds.push(import_drizzle_orm9.sql`type = ${type}`);
2980
+ if (orphaned) conds.push(import_drizzle_orm9.sql`reference_count = 0`);
2981
+ const where = conds.length > 0 ? import_drizzle_orm9.sql` WHERE ${import_drizzle_orm9.sql.join(conds, import_drizzle_orm9.sql` AND `)}` : import_drizzle_orm9.sql``;
2982
+ const countResult = await db.execute(import_drizzle_orm9.sql`SELECT COUNT(*) AS total FROM ${TABLE}${where}`);
2983
+ const total = Number(
2984
+ countResult.rows[0]?.["total"] ?? 0
2985
+ );
2986
+ const dataResult = await db.execute(
2987
+ import_drizzle_orm9.sql`SELECT * FROM ${TABLE}${where} ORDER BY created_at DESC LIMIT ${per_page} OFFSET ${offset}`
2988
+ );
2989
+ const total_pages = Math.ceil(total / per_page);
2990
+ return {
2991
+ ok: true,
2992
+ data: dataResult.rows,
2993
+ meta: {
2994
+ total,
2995
+ page,
2996
+ per_page,
2997
+ total_pages,
2998
+ has_next: page < total_pages,
2999
+ has_prev: page > 1
3000
+ }
3001
+ };
3002
+ },
3003
+ async findOne(id) {
3004
+ const result = await db.execute(
3005
+ import_drizzle_orm9.sql`SELECT * FROM ${TABLE} WHERE id = ${id} LIMIT 1`
3006
+ );
3007
+ if (result.rows.length === 0) return null;
3008
+ return result.rows[0];
3009
+ },
3010
+ async create(data) {
3011
+ const record = {
3012
+ ...data,
3013
+ id: import_node_crypto4.default.randomUUID(),
3014
+ reference_count: 0,
3015
+ created_at: /* @__PURE__ */ new Date(),
3016
+ updated_at: /* @__PURE__ */ new Date()
3017
+ };
3018
+ const entries = Object.entries(record).filter(([, v]) => v !== void 0);
3019
+ const colsSql = import_drizzle_orm9.sql.join(entries.map(([k]) => import_drizzle_orm9.sql.raw(`"${k}"`)), import_drizzle_orm9.sql`, `);
3020
+ const valsSql = import_drizzle_orm9.sql.join(entries.map(([, v]) => import_drizzle_orm9.sql`${v}`), import_drizzle_orm9.sql`, `);
3021
+ const result = await db.execute(
3022
+ import_drizzle_orm9.sql`INSERT INTO ${TABLE} (${colsSql}) VALUES (${valsSql}) RETURNING *`
3023
+ );
3024
+ return result.rows[0];
3025
+ },
3026
+ async update(id, data) {
3027
+ const record = { ...data, updated_at: /* @__PURE__ */ new Date() };
3028
+ const entries = Object.entries(record).filter(
3029
+ ([k, v]) => k !== "id" && v !== void 0
3030
+ );
3031
+ if (entries.length === 0) return this.findOne(id);
3032
+ const setClauses = import_drizzle_orm9.sql.join(
3033
+ entries.map(([k, v]) => import_drizzle_orm9.sql`${import_drizzle_orm9.sql.raw(`"${k}"`)} = ${v}`),
3034
+ import_drizzle_orm9.sql`, `
3035
+ );
3036
+ const result = await db.execute(
3037
+ import_drizzle_orm9.sql`UPDATE ${TABLE} SET ${setClauses} WHERE id = ${id} RETURNING *`
3038
+ );
3039
+ if (result.rows.length === 0) return null;
3040
+ return result.rows[0];
3041
+ },
3042
+ async delete(id) {
3043
+ await db.execute(import_drizzle_orm9.sql`DELETE FROM ${TABLE} WHERE id = ${id}`);
3044
+ },
3045
+ async incrementReferenceCount(ids) {
3046
+ if (ids.length === 0) return;
3047
+ const inList = import_drizzle_orm9.sql.join(ids.map((id) => import_drizzle_orm9.sql`${id}`), import_drizzle_orm9.sql`, `);
3048
+ await db.execute(
3049
+ import_drizzle_orm9.sql`UPDATE ${TABLE} SET reference_count = reference_count + 1, updated_at = NOW() WHERE id IN (${inList})`
3050
+ );
3051
+ },
3052
+ async decrementReferenceCount(ids) {
3053
+ if (ids.length === 0) return;
3054
+ const inList = import_drizzle_orm9.sql.join(ids.map((id) => import_drizzle_orm9.sql`${id}`), import_drizzle_orm9.sql`, `);
3055
+ await db.execute(
3056
+ import_drizzle_orm9.sql`UPDATE ${TABLE} SET reference_count = GREATEST(reference_count - 1, 0), updated_at = NOW() WHERE id IN (${inList})`
3057
+ );
3058
+ }
3059
+ };
3060
+ }
3061
+
3062
+ // src/app.ts
3063
+ var MISSING_STORAGE_ERROR = `\u2717 api.storage is required but not configured.
3064
+ Add a storage adapter to your manguito.config.ts:
3065
+
3066
+ api: createAPIAdapter({
3067
+ storage: createLocalAdapter(), // dev
3068
+ // storage: createS3Adapter({ bucket: '...', region: '...' }) // production
3069
+ })
3070
+
3071
+ Exiting.`;
3072
+ function createCmsApp(options) {
3073
+ if (!options.storage) {
3074
+ throw new Error(MISSING_STORAGE_ERROR);
3075
+ }
3076
+ const prefix = options.prefix ?? "/api";
3077
+ const { storage, registry, db, rateLimit, media, cors } = options;
3078
+ const cmsName = options.name ?? "Manguito CMS";
3079
+ const maxFileSize = media?.max_file_size;
3080
+ const rolesRegistry = buildRolesRegistry(registry.roles.roles);
3081
+ const app = new import_hono.Hono();
3082
+ const uploadOrigins = storage.getUploadOrigins?.() ?? [];
3083
+ app.use("*", createSecurityHeadersMiddleware({ connectSrc: uploadOrigins }));
3084
+ app.use("*", createCorsMiddleware(cors ?? { origin: "*", enabled: true }));
3085
+ app.onError(errorHandler);
3086
+ const listRateLimit = resolveListRateLimit(rateLimit);
3087
+ const authMiddleware = createAuthMiddleware(db);
3088
+ const requirePermission = createPermissionMiddleware(rolesRegistry);
3089
+ const getUserRole = async (userId) => {
3090
+ const result = await db.execute(
3091
+ import_drizzle_orm10.sql`SELECT r.name AS role
3092
+ FROM users u
3093
+ JOIN roles r ON r.id = u.role_id
3094
+ WHERE u.id = ${userId}
3095
+ LIMIT 1`
3096
+ );
3097
+ return result.rows[0]?.role ?? null;
3098
+ };
3099
+ const requireHierarchy = createHierarchyMiddleware(rolesRegistry, getUserRole);
3100
+ const contentRepos = Object.fromEntries(
3101
+ Object.entries(registry.content_types).map(([typeName, ct]) => [
3102
+ typeName,
3103
+ createDrizzleContentRepository(db, ct.db.table_name)
3104
+ ])
3105
+ );
3106
+ const taxonomyRepos = Object.fromEntries(
3107
+ Object.entries(registry.taxonomy_types).map(([typeName, tt]) => [
3108
+ typeName,
3109
+ createDrizzleContentRepository(db, tt.db.table_name)
3110
+ ])
3111
+ );
3112
+ const repos = { ...contentRepos, ...taxonomyRepos };
3113
+ const mediaRepo = createMediaRepository(db);
3114
+ const publicContentRepos = Object.fromEntries(
3115
+ Object.entries(registry.content_types).map(([typeName, ct]) => [
3116
+ typeName,
3117
+ createDrizzleContentRepository(db, ct.db.table_name, {
3118
+ relations: buildRelationsMap(ct.fields, registry)
3119
+ })
3120
+ ])
3121
+ );
3122
+ const publicTaxonomyRepos = Object.fromEntries(
3123
+ Object.entries(registry.taxonomy_types).map(([typeName, tt]) => [
3124
+ typeName,
3125
+ createDrizzleContentRepository(db, tt.db.table_name, {
3126
+ relations: buildRelationsMap(tt.fields, registry)
3127
+ })
3128
+ ])
3129
+ );
3130
+ const publicRepos = { ...publicContentRepos, ...publicTaxonomyRepos };
3131
+ registerAuthRoutes(app, db);
3132
+ app.use("/admin/api/*", authMiddleware);
3133
+ app.use("/admin/api/*", mustChangePasswordCheck);
3134
+ app.get("/api/openapi.json", (c) => {
3135
+ const paths = {};
3136
+ for (const ct of Object.values(registry.content_types)) {
3137
+ const base = ct.default_base_path;
3138
+ if (ct.only_one) {
3139
+ paths[`/api/${base}`] = { get: { summary: `Get ${ct.label}`, tags: [ct.label] } };
3140
+ } else {
3141
+ paths[`/api/${base}`] = { get: { summary: `List published ${ct.label}`, tags: [ct.label] } };
3142
+ paths[`/api/${base}/{slug}`] = { get: { summary: `Get ${ct.label} by slug`, tags: [ct.label] } };
3143
+ }
3144
+ }
3145
+ for (const tt of Object.values(registry.taxonomy_types)) {
3146
+ paths[`/api/taxonomy/${tt.name}`] = { get: { summary: `List published ${tt.label}`, tags: [tt.label] } };
3147
+ paths[`/api/taxonomy/${tt.name}/{id}`] = { get: { summary: `Get ${tt.label} by id`, tags: [tt.label] } };
3148
+ }
3149
+ paths["/api/media"] = { get: { summary: "List media items", tags: ["Media"] } };
3150
+ paths["/api/media/{id}"] = { get: { summary: "Get media item by id", tags: ["Media"] } };
3151
+ return c.json({
3152
+ openapi: "3.0.3",
3153
+ info: { title: "Manguito CMS Public API", version: "1.0.0" },
3154
+ paths
3155
+ });
3156
+ });
3157
+ app.get(
3158
+ "/admin/api/openapi.json",
3159
+ (c) => c.json({
3160
+ openapi: "3.0.3",
3161
+ info: { title: "Manguito CMS Admin API", version: "1.0.0" },
3162
+ paths: {}
3163
+ })
3164
+ );
3165
+ registerPublicContentRoutes(app, registry, publicRepos, listRateLimit);
3166
+ registerPublicMediaRoutes(app, mediaRepo, listRateLimit);
3167
+ registerConfigRoute(
3168
+ app,
3169
+ {
3170
+ name: cmsName,
3171
+ ...maxFileSize !== void 0 && { maxFileSize },
3172
+ // Cloud storage (s3/cloudinary) issues real presigned URLs, so the admin
3173
+ // uploads straight to it — never routing the file through the server
3174
+ // (which breaks on serverless). Local storage has no presigned receiver,
3175
+ // so it uses the direct upload endpoint.
3176
+ presignedUploads: storage.type !== "local"
3177
+ },
3178
+ rolesRegistry,
3179
+ db
3180
+ );
3181
+ registerSchemaRoute(app, registry, db);
3182
+ registerUserRoutes(app, db, requirePermission, requireHierarchy);
3183
+ registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePermission, db);
3184
+ registerAdminMediaRoutes(app, mediaRepo, storage, requirePermission, maxFileSize);
3185
+ return { prefix, app };
3186
+ }
3187
+
3188
+ // src/server/node.ts
3189
+ function createServer(options = {}) {
3190
+ const port = options.port ?? Number(process.env["PORT"] ?? 3e3);
3191
+ const _base_url = options.base_url ?? `http://localhost:${port}`;
3192
+ const cors = {
3193
+ origin: options.cors?.origin ?? (process.env["ALLOWED_ORIGIN"] ?? "*"),
3194
+ methods: options.cors?.methods ?? [
3195
+ "GET",
3196
+ "POST",
3197
+ "PUT",
3198
+ "PATCH",
3199
+ "DELETE"
3200
+ ],
3201
+ credentials: options.cors?.credentials ?? true
3202
+ };
3203
+ return {
3204
+ type: "node",
3205
+ cors,
3206
+ getEntryPoint() {
3207
+ return "node";
3208
+ }
3209
+ };
3210
+ }
3211
+
3212
+ // src/index.ts
3213
+ function createAPIAdapter(options = {}) {
3214
+ const prefix = options.prefix ?? "/api";
3215
+ const media = options.media?.max_file_size !== void 0 ? { max_file_size: options.media.max_file_size } : void 0;
3216
+ return {
3217
+ prefix,
3218
+ ...media !== void 0 && { media },
3219
+ ...options.rateLimit !== void 0 && { rateLimit: options.rateLimit }
3220
+ };
3221
+ }
3222
+ // Annotate the CommonJS export names for ESM import in node:
3223
+ 0 && (module.exports = {
3224
+ createAPIAdapter,
3225
+ createCmsApp,
3226
+ createServer
3227
+ });
3228
+ //# sourceMappingURL=index.js.map