@beechcms/api 0.6.0-preview.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/chart-CspfxV2U.js +39 -0
  4. package/assets/dashboard/assets/index-DtDfJyaF.js +644 -0
  5. package/assets/dashboard/assets/index-Dve7eEqA.css +1 -0
  6. package/assets/dashboard/assets/{pie-chart-recharts-BMWpjFzf.js → pie-chart-recharts-Bx2IXAjA.js} +1 -1
  7. package/assets/dashboard/assets/timeseries-chart-recharts-BCWufwI1.js +1 -0
  8. package/assets/dashboard/beechLogoDark.svg +48 -48
  9. package/assets/dashboard/index.html +17 -18
  10. package/assets/dashboard/noResult.svg +42 -42
  11. package/assets/dashboard/sol.svg +3 -3
  12. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  13. package/dist/auth/constants.d.ts +5 -5
  14. package/dist/features/automations/{automations.handler.d.ts → api/automations.handler.d.ts} +1 -1
  15. package/dist/features/automations/{automation-runner.utils.d.ts → engine/automation-runner.utils.d.ts} +1 -1
  16. package/dist/features/automations/{action-executors → executors}/edit-field.executor.d.ts +1 -1
  17. package/dist/features/automations/{action-executors → executors}/index.d.ts +1 -1
  18. package/dist/features/automations/{action-executors → executors}/send-mail.executor.d.ts +1 -1
  19. package/dist/features/automations/{action-executors → executors}/set-variable.executor.d.ts +1 -1
  20. package/dist/features/automations/{action-executors → executors}/webhook.executor.d.ts +1 -1
  21. package/dist/features/automations/{when-evaluator.d.ts → filters/when-evaluator.d.ts} +1 -1
  22. package/dist/features/automations/index.d.ts +7 -7
  23. package/dist/features/content/constants.d.ts +10 -10
  24. package/dist/features/content/handlers/kanban-move.d.ts +3 -0
  25. package/dist/features/content/handlers/kanban-position.d.ts +3 -0
  26. package/dist/features/content/handlers/view-config.d.ts +4 -0
  27. package/dist/{search.d.ts → features/search/search.d.ts} +1 -1
  28. package/dist/features/upload/index.d.ts +9 -0
  29. package/dist/{widget.d.ts → features/widget/widget.d.ts} +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.js +1091 -439
  32. package/dist/{middleware.d.ts → middleware/auth.middleware.d.ts} +1 -1
  33. package/dist/middleware/repository.middleware.d.ts +2 -1
  34. package/dist/public/access-policy.d.ts +1 -1
  35. package/dist/public/problem-details.d.ts +1 -0
  36. package/dist/public/public-errors.d.ts +4 -4
  37. package/dist/rate-limit/cloudflare-rate-limiter.d.ts +4 -1
  38. package/dist/rate-limit/in-memory-rate-limiter.d.ts +2 -1
  39. package/dist/shared/{schema-mutator.d1.d.ts → db/migrations/schema-mutator.d1.d.ts} +1 -0
  40. package/dist/shared/db/repositories/content.repository.d1.d.ts +259 -0
  41. package/dist/shared/db/repositories/kanban-position.repository.d1.d.ts +9 -0
  42. package/dist/shared/{seed-layout.repository.d1.d.ts → db/repositories/seed-layout.repository.d1.d.ts} +3 -1
  43. package/dist/shared/email/email.provider.d.ts +1 -0
  44. package/dist/{features → shared}/email/email.types.d.ts +1 -16
  45. package/dist/{features → shared}/email/providers/resend.d.ts +1 -2
  46. package/dist/{features → shared}/email/providers/smtp.d.ts +1 -2
  47. package/dist/shared/jobs/queue-consumer.d.ts +9 -0
  48. package/dist/shared/services/queue/cloudflare-queue-service.d.ts +11 -0
  49. package/dist/shared/services/queue/in-memory-queue-service.d.ts +16 -0
  50. package/dist/shared/storage/s3-bucket.d.ts +0 -1
  51. package/dist/shared/storage/upload.d.ts +7 -0
  52. package/dist/{media-utils.d.ts → shared/utils/media-utils.d.ts} +4 -2
  53. package/dist/types.d.ts +93 -2
  54. package/migrations/0000_v040_base.sql +265 -265
  55. package/migrations/0029_automations.sql +14 -14
  56. package/migrations/0030_test_seeds.sql +125 -125
  57. package/package.json +5 -4
  58. package/assets/dashboard/assets/chart-BwlCt-IA.js +0 -39
  59. package/assets/dashboard/assets/index-BcVN-DNy.js +0 -642
  60. package/assets/dashboard/assets/index-Cf1yRCqG.css +0 -1
  61. package/assets/dashboard/assets/timeseries-chart-recharts-8_GwAFR0.js +0 -1
  62. package/dist/features/email/email.provider.d.ts +0 -37
  63. package/dist/shared/content.repository.d1.d.ts +0 -68
  64. package/dist/upload.d.ts +0 -16
  65. package/dist/auth/{bcrypt-hash-provider.d.ts → providers/hash.provider.d.ts} +0 -0
  66. package/dist/auth/{jose-token-service.d.ts → providers/jwt-token.service.d.ts} +0 -0
  67. package/dist/auth/{login.d.ts → utils/login-helpers.d.ts} +0 -0
  68. package/dist/auth/{refresh.d.ts → utils/refresh-token.d.ts} +0 -0
  69. package/dist/features/automations/{automations.schema.d.ts → api/automations.schema.d.ts} +16 -16
  70. /package/dist/features/automations/{automation-runner.d.ts → engine/automation-runner.d.ts} +0 -0
  71. /package/dist/features/automations/{cron-runner.d.ts → engine/cron-runner.d.ts} +0 -0
  72. /package/dist/features/automations/{cron-runner.utils.d.ts → engine/cron-runner.utils.d.ts} +0 -0
  73. /package/dist/features/automations/{context-resolver.d.ts → evaluator/context-resolver.d.ts} +0 -0
  74. /package/dist/features/automations/{template-grammar.d.ts → evaluator/template-grammar.d.ts} +0 -0
  75. /package/dist/features/automations/{var-access-resolver.d.ts → evaluator/var-access-resolver.d.ts} +0 -0
  76. /package/dist/features/automations/{action-executors → executors}/create-entry.executor.d.ts +0 -0
  77. /package/dist/features/automations/{filter-translation.d.ts → filters/filter-translation.d.ts} +0 -0
  78. /package/dist/features/automations/{when-pushdown.d.ts → filters/when-pushdown.d.ts} +0 -0
  79. /package/dist/{search-utils.d.ts → features/search/search-utils.d.ts} +0 -0
  80. /package/dist/shared/{demo-data-sql.d.ts → db/migrations/demo-data-sql.d.ts} +0 -0
  81. /package/dist/shared/{automations.repository.d1.d.ts → db/repositories/automations.repository.d1.d.ts} +0 -0
  82. /package/dist/shared/{base.repository.d1.d.ts → db/repositories/base.repository.d1.d.ts} +0 -0
  83. /package/dist/shared/{d1-activity-log.repository.d.ts → db/repositories/d1-activity-log.repository.d.ts} +0 -0
  84. /package/dist/shared/{d1-analytics.repository.d.ts → db/repositories/d1-analytics.repository.d.ts} +0 -0
  85. /package/dist/shared/{d1-content-scan.repository.d.ts → db/repositories/d1-content-scan.repository.d.ts} +0 -0
  86. /package/dist/shared/{d1-notification.repository.d.ts → db/repositories/d1-notification.repository.d.ts} +0 -0
  87. /package/dist/shared/{d1-password-reset-token.repository.d.ts → db/repositories/d1-password-reset-token.repository.d.ts} +0 -0
  88. /package/dist/shared/{d1-search.repository.d.ts → db/repositories/d1-search.repository.d.ts} +0 -0
  89. /package/dist/shared/{d1-session.repository.d.ts → db/repositories/d1-session.repository.d.ts} +0 -0
  90. /package/dist/shared/{d1-setup-checklist.repository.d.ts → db/repositories/d1-setup-checklist.repository.d.ts} +0 -0
  91. /package/dist/shared/{d1-user.repository.d.ts → db/repositories/d1-user.repository.d.ts} +0 -0
  92. /package/dist/shared/{d1-widget.repository.d.ts → db/repositories/d1-widget.repository.d.ts} +0 -0
  93. /package/dist/shared/{dashboard-layout.repository.d1.d.ts → db/repositories/dashboard-layout.repository.d1.d.ts} +0 -0
  94. /package/dist/shared/{demo-data.repository.d1.d.ts → db/repositories/demo-data.repository.d1.d.ts} +0 -0
  95. /package/dist/shared/{idempotency.repository.d1.d.ts → db/repositories/idempotency.repository.d1.d.ts} +0 -0
  96. /package/dist/shared/{in-memory-seed.repository.d.ts → db/repositories/in-memory-seed.repository.d.ts} +0 -0
  97. /package/dist/shared/{media.repository.d1.d.ts → db/repositories/media.repository.d1.d.ts} +0 -0
  98. /package/dist/shared/{seed.repository.d1.d.ts → db/repositories/seed.repository.d1.d.ts} +0 -0
  99. /package/dist/shared/{site-settings.repository.d1.d.ts → db/repositories/site-settings.repository.d1.d.ts} +0 -0
  100. /package/dist/shared/{system-stats.repository.d1.d.ts → db/repositories/system-stats.repository.d1.d.ts} +0 -0
  101. /package/dist/{features → shared}/email/email.service.d.ts +0 -0
  102. /package/dist/{features → shared}/email/index.d.ts +0 -0
  103. /package/dist/{features → shared}/email/templates/automation-mail.d.ts +0 -0
  104. /package/dist/{features → shared}/email/templates/password-changed.d.ts +0 -0
  105. /package/dist/{features → shared}/email/templates/password-reset.d.ts +0 -0
  106. /package/dist/{features → shared}/email/templates/shell.d.ts +0 -0
  107. /package/dist/shared/{fts-sync.d.ts → jobs/fts-sync.d.ts} +0 -0
  108. /package/dist/shared/{apply-policies.d.ts → policies/apply-policies.d.ts} +0 -0
  109. /package/dist/shared/{d1-activity-logger.d.ts → services/activity-log/d1-activity-logger.d.ts} +0 -0
  110. /package/dist/shared/{in-memory-activity-logger.d.ts → services/activity-log/in-memory-activity-logger.d.ts} +0 -0
  111. /package/dist/shared/{seed-registry-cache.d.ts → services/cache/seed-registry-cache.d.ts} +0 -0
  112. /package/dist/shared/{fixed-clock.d.ts → services/clock/fixed-clock.d.ts} +0 -0
  113. /package/dist/shared/{sequential-id-generator.d.ts → services/id-generator/sequential-id-generator.d.ts} +0 -0
  114. /package/dist/shared/{background-notification-service.d.ts → services/notification/background-notification-service.d.ts} +0 -0
  115. /package/dist/shared/{in-memory-notification-service.d.ts → services/notification/in-memory-notification-service.d.ts} +0 -0
  116. /package/dist/shared/{qstash-notification-service.d.ts → services/notification/qstash-notification-service.d.ts} +0 -0
  117. /package/dist/shared/{execution-context-scheduler.d.ts → services/scheduler/execution-context-scheduler.d.ts} +0 -0
  118. /package/dist/shared/{content-utils.d.ts → utils/content-utils.d.ts} +0 -0
  119. /package/dist/shared/{query-utils.d.ts → utils/query-utils.d.ts} +0 -0
  120. /package/dist/shared/{request-utils.d.ts → utils/request-utils.d.ts} +0 -0
  121. /package/dist/shared/{storage-utils.d.ts → utils/storage-utils.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { cors } from "hono/cors";
4
4
  import { getCookie, setCookie, deleteCookie } from "hono/cookie";
5
5
  import { sha256hex as sha256hex7, SystemClock as SystemClock6, SystemIdGenerator as SystemIdGenerator4 } from "@beechcms/core";
6
6
 
7
- // src/shared/request-utils.ts
7
+ // src/shared/utils/request-utils.ts
8
8
  var CLOUDFLARE_CLIENT_IP_HEADER = "cf-connecting-ip";
9
9
  var UNKNOWN_IP = "unknown";
10
10
  function getClientIp(request) {
@@ -22,11 +22,12 @@ var AUTH_ERRORS = {
22
22
  RATE_LIMIT_EXCEEDED: "Too many requests"
23
23
  };
24
24
 
25
- // src/auth/login.ts
25
+ // src/auth/utils/login-helpers.ts
26
26
  var EMAIL_REGEX = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
27
27
  var MAX_EMAIL_LENGTH = 254;
28
28
  var MIN_PASSWORD_LENGTH = 8;
29
29
  var MAX_PASSWORD_LENGTH = 128;
30
+ var MAX_PASSWORD_BYTES = 72;
30
31
  var DUMMY_PASSWORD_HASH = "$2a$10$SbkRFOafACxVM2ahxerVDu3tSkCXWm29b62WdB.4WGG02Qjsfzni6";
31
32
  function parseLoginBody(body) {
32
33
  if (body === null || typeof body !== "object") return null;
@@ -35,23 +36,23 @@ function parseLoginBody(body) {
35
36
  const password = obj.password;
36
37
  if (typeof email !== "string" || typeof password !== "string") return null;
37
38
  if (!email.trim() || !password) return null;
38
- return { email: email.trim(), password };
39
+ return { email: email.trim().toLowerCase(), password };
39
40
  }
40
41
  function validateLoginInput(email, password) {
41
- return email.length <= MAX_EMAIL_LENGTH && EMAIL_REGEX.test(email) && password.trim().length >= MIN_PASSWORD_LENGTH && password.length <= MAX_PASSWORD_LENGTH;
42
+ return email.length <= MAX_EMAIL_LENGTH && EMAIL_REGEX.test(email) && password.length >= MIN_PASSWORD_LENGTH && password.length <= MAX_PASSWORD_LENGTH && new TextEncoder().encode(password).length <= MAX_PASSWORD_BYTES;
42
43
  }
43
44
  async function verifyPassword(plainPassword, hash, hashProvider) {
44
45
  return hashProvider.verify(plainPassword, hash);
45
46
  }
46
47
 
47
- // src/auth/refresh.ts
48
+ // src/auth/utils/refresh-token.ts
48
49
  function generateRefreshToken() {
49
50
  const bytes = new Uint8Array(32);
50
51
  crypto.getRandomValues(bytes);
51
52
  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
52
53
  }
53
54
 
54
- // src/middleware.ts
55
+ // src/middleware/auth.middleware.ts
55
56
  import { HTTPException } from "hono/http-exception";
56
57
  var UNAUTHORIZED_JSON = JSON.stringify({ error: "Unauthorized" });
57
58
  function unauthorizedResponse() {
@@ -80,9 +81,9 @@ function authMiddleware() {
80
81
  }
81
82
 
82
83
  // src/features/content/index.ts
83
- import { Hono as Hono2 } from "hono";
84
+ import { Hono } from "hono";
84
85
 
85
- // src/shared/query-utils.ts
86
+ // src/shared/utils/query-utils.ts
86
87
  var QUERY_FILTER_OPERATOR_SET = /* @__PURE__ */ new Set([
87
88
  "eq",
88
89
  "gt",
@@ -163,7 +164,7 @@ function toEngineFilters(groups) {
163
164
  }));
164
165
  }
165
166
 
166
- // src/shared/apply-policies.ts
167
+ // src/shared/policies/apply-policies.ts
167
168
  import { resolvePolicies, sha256hex } from "@beechcms/core";
168
169
  var PrivacyPolicyError = class extends Error {
169
170
  status = 501;
@@ -277,7 +278,8 @@ function publicProblem(c, input) {
277
278
  body.errors = input.errors;
278
279
  }
279
280
  return c.json(body, input.status, {
280
- "Content-Type": "application/problem+json"
281
+ "Content-Type": "application/problem+json",
282
+ ...input.headers
281
283
  });
282
284
  }
283
285
 
@@ -296,6 +298,7 @@ var CONTENT_ERRORS = {
296
298
  };
297
299
 
298
300
  // src/features/content/handlers/list.ts
301
+ import { resolveKanbanConfig } from "@beechcms/core";
299
302
  async function buildRelationsMap(context, seed, entries) {
300
303
  const relationBranches = seed.branches.filter(
301
304
  (b) => b.type === "relation"
@@ -333,9 +336,9 @@ async function buildRelationsMap(context, seed, entries) {
333
336
  });
334
337
  const map = {};
335
338
  for (const item of items) {
336
- const id = item.id;
337
- const data = item.data;
338
- const label = data[labelAlias];
339
+ const row = item;
340
+ const id = row.id;
341
+ const label = row[labelAlias];
339
342
  map[id] = label != null && label !== "" ? String(label) : id;
340
343
  }
341
344
  relations[branch.alias] = map;
@@ -374,12 +377,44 @@ async function listHandler(context) {
374
377
  const limit = Math.min(parsePositiveInt(query.limit, 25), 100);
375
378
  const offset = (page - 1) * limit;
376
379
  const orderBy = sortBy ? { column: sortBy, dir: sortDirRaw === "desc" ? "DESC" : "ASC" } : void 0;
380
+ const kanbanAxis = cleanStr(query.kanbanAxis);
381
+ let kanbanOrder;
382
+ if (kanbanAxis) {
383
+ const compat = resolveKanbanConfig(seed);
384
+ if (!compat.compatible || !compat.candidates.some((c) => c.branchId === kanbanAxis)) {
385
+ return publicProblem(context, {
386
+ type: "content-invalid-kanban-axis",
387
+ title: "Bad Request",
388
+ status: 400,
389
+ detail: "kanbanAxis is not a valid candidate for this seed"
390
+ });
391
+ }
392
+ kanbanOrder = { seedSlug: slug, axisBranchId: kanbanAxis };
393
+ }
394
+ let allFilters = engineFilters;
395
+ if (kanbanOrder) {
396
+ const rawFilters2 = cleanStr(query.filters);
397
+ if (rawFilters2) {
398
+ try {
399
+ const parsed = JSON.parse(rawFilters2);
400
+ if (Array.isArray(parsed)) {
401
+ allFilters = parsed.filter(
402
+ (g) => g !== null && typeof g === "object" && typeof g.column === "string" && Array.isArray(g.conditions)
403
+ );
404
+ }
405
+ } catch {
406
+ }
407
+ } else {
408
+ allFilters = [];
409
+ }
410
+ }
377
411
  const repository = context.get("repository");
378
412
  const { items, total } = await repository.findMany(seed, {
379
- filters: engineFilters,
380
- orderBy,
413
+ filters: allFilters,
414
+ orderBy: kanbanOrder ? void 0 : orderBy,
381
415
  search: search || void 0,
382
- pagination: { limit, offset }
416
+ pagination: { limit, offset },
417
+ kanbanOrder
383
418
  });
384
419
  const entries = await Promise.all(items.map(async (item) => {
385
420
  let hasPendingDraft = false;
@@ -393,7 +428,7 @@ async function listHandler(context) {
393
428
  // Repository returns "pure" data including system fields
394
429
  };
395
430
  }));
396
- const hasQueryParams = Boolean(search) || Boolean(sortBy) || Boolean(query.filters) || query.page !== void 0 || query.limit !== void 0;
431
+ const hasQueryParams = Boolean(search) || Boolean(sortBy) || Boolean(query.filters) || Boolean(kanbanAxis) || query.page !== void 0 || query.limit !== void 0;
397
432
  if (!hasQueryParams) {
398
433
  return context.json(entries);
399
434
  }
@@ -822,173 +857,47 @@ async function updateHandler(context) {
822
857
  }
823
858
  }
824
859
 
825
- // src/upload.ts
826
- import { Hono } from "hono";
827
- import { isMimeAccepted } from "@beechcms/core";
828
- var DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
829
- var ABSOLUTE_MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
830
- var PRESIGN_TTL_SECONDS = 900;
831
- function resolveMaxUploadBytes(env) {
832
- const raw = env.MAX_UPLOAD_BYTES;
833
- if (!raw) return DEFAULT_MAX_UPLOAD_BYTES;
834
- const parsed = Number.parseInt(raw, 10);
835
- if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_UPLOAD_BYTES;
836
- return Math.min(parsed, ABSOLUTE_MAX_UPLOAD_BYTES);
837
- }
838
- function sanitizeFilename(name) {
839
- const base = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 100);
840
- return base || "file";
841
- }
842
- function generateObjectKey(originalName) {
843
- const timestamp = Math.floor(Date.now() / 1e3);
844
- return `${timestamp}-${sanitizeFilename(originalName)}`;
845
- }
860
+ // src/shared/storage/upload.ts
846
861
  async function deleteR2Objects(c, objectKeys) {
847
862
  const { bucket, mediaRepository, systemStatsRepository } = c.var;
848
- for (const key of objectKeys) {
849
- const media = await mediaRepository.getByKey(key).catch(() => null);
850
- const size = media?.size_bytes ?? 0;
851
- try {
863
+ await Promise.all(
864
+ objectKeys.map(async (key) => {
865
+ const media = await mediaRepository.getByKey(key).catch(() => null);
866
+ if (!media) {
867
+ throw new Error(`Media object not found: ${key}`);
868
+ }
869
+ const size = media.size_bytes ?? 0;
852
870
  await bucket.delete(key);
853
- } catch (err) {
854
- console.warn(`Failed to delete storage object: ${key}`, err);
855
- }
856
- try {
857
- await mediaRepository.untrack(key);
858
- if (size > 0) await systemStatsRepository.decrementStorage(size);
859
- } catch (err) {
860
- console.warn(`Failed to untrack media object: ${key}`, err);
861
- }
862
- }
863
- }
864
- var uploadRoutes = new Hono();
865
- uploadRoutes.post("/upload/presign", async (c) => {
866
- let body;
867
- try {
868
- body = await c.req.json();
869
- } catch {
870
- return c.json({ error: "Invalid JSON body" }, 400);
871
- }
872
- const { filename, mimeType, sizeBytes } = body;
873
- if (typeof filename !== "string" || !filename.trim()) return c.json({ error: "filename is required" }, 400);
874
- if (typeof mimeType !== "string" || !mimeType.trim()) return c.json({ error: "mimeType is required" }, 400);
875
- if (typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || sizeBytes <= 0) {
876
- return c.json({ error: "sizeBytes must be a positive number" }, 400);
877
- }
878
- const maxBytes = resolveMaxUploadBytes(c.env);
879
- if (sizeBytes > maxBytes) return c.json({ error: `File too large. Max ${maxBytes} bytes` }, 400);
880
- if (!isMimeAccepted(mimeType, "any")) return c.json({ error: "File type not allowed" }, 400);
881
- const key = generateObjectKey(filename);
882
- const uploadUrl = await c.var.bucket.presignPut(key, {
883
- expiresIn: PRESIGN_TTL_SECONDS,
884
- contentType: mimeType,
885
- contentLength: sizeBytes
886
- });
887
- return c.json({ uploadUrl, key, expiresIn: PRESIGN_TTL_SECONDS }, 200);
888
- });
889
- uploadRoutes.post("/upload/confirm", async (c) => {
890
- const { bucket, mediaRepository: mediaRepo, systemStatsRepository: statsRepo } = c.var;
891
- let body;
892
- try {
893
- body = await c.req.json();
894
- } catch {
895
- return c.json({ error: "Invalid JSON body" }, 400);
896
- }
897
- const { key } = body;
898
- if (typeof key !== "string" || !key.trim()) return c.json({ error: "key is required" }, 400);
899
- const existing = await mediaRepo.getByKey(key);
900
- if (existing) return c.json({ url: bucket.getUrl(key) }, 200);
901
- const head = await bucket.head(key);
902
- if (!head) return c.json({ error: "Object not found in storage" }, 404);
903
- const uploadedBy = c.var.jwtPayload?.sub ?? "";
904
- const size = head.size ?? 0;
905
- const mime = head.contentType ?? "application/octet-stream";
906
- const filename = key.replace(/^\d+-/, "") || key;
907
- await statsRepo.incrementStorage(size);
908
- await mediaRepo.trackUpload({
909
- key,
910
- filename,
911
- mime_type: mime,
912
- size_bytes: size,
913
- uploaded_by: uploadedBy
914
- });
915
- const jwtPayload = c.get("jwtPayload");
916
- if (jwtPayload) {
917
- c.get("activityLogger").log({
918
- action: "upload",
919
- entityType: "media",
920
- entityId: key,
921
- details: { name: filename, size, type: mime },
922
- actor: {
923
- id: jwtPayload.sub,
924
- email: jwtPayload.email ?? "unknown",
925
- name: [jwtPayload.name, jwtPayload.surname].filter(Boolean).join(" ") || null
871
+ try {
872
+ await mediaRepository.untrack(key);
873
+ if (size > 0) {
874
+ await systemStatsRepository.decrementStorage(size);
875
+ }
876
+ } catch (err) {
877
+ console.warn(`Failed to untrack media object: ${key}`, err);
926
878
  }
927
- });
928
- }
929
- return c.json({ url: bucket.getUrl(key) }, 200);
930
- });
931
- uploadRoutes.get("/upload/download-url/:key", async (c) => {
932
- const key = decodeURIComponent(c.req.param("key") ?? "");
933
- if (!key) return c.json({ error: "Missing key" }, 400);
934
- const head = await c.var.bucket.head(key);
935
- if (!head) return c.json({ error: "Object not found" }, 404);
936
- const downloadUrl = await c.var.bucket.presignGet(key, { expiresIn: PRESIGN_TTL_SECONDS });
937
- return c.json({ downloadUrl, expiresIn: PRESIGN_TTL_SECONDS }, 200);
938
- });
939
- uploadRoutes.delete("/upload/:key", async (c) => {
940
- const key = c.req.param("key");
941
- if (!key) return c.json({ error: "Missing key" }, 400);
942
- await deleteR2Objects(c, [decodeURIComponent(key)]);
943
- return c.json({ success: true }, 200);
944
- });
945
- var FORCE_DOWNLOAD_MIME_PREFIXES = [
946
- "image/svg",
947
- // image/svg+xml, image/svg
948
- "text/",
949
- // text/html, text/xml, text/javascript, …
950
- "application/xml",
951
- "application/xhtml",
952
- "application/javascript",
953
- "application/x-javascript"
954
- ];
955
- function isActiveMimeType(mime) {
956
- const lower = mime.toLowerCase();
957
- return FORCE_DOWNLOAD_MIME_PREFIXES.some((prefix) => lower.startsWith(prefix));
879
+ })
880
+ );
958
881
  }
959
- async function serveMediaHandler(c) {
960
- const key = c.req.param("key");
961
- if (!key) return new Response("Missing key", { status: 400 });
962
- try {
963
- const object = await c.var.bucket.get(decodeURIComponent(key));
964
- if (!object) return new Response("Not found", { status: 404 });
965
- const headers = new Headers();
966
- const originalMime = object.contentType ?? "application/octet-stream";
967
- if (isActiveMimeType(originalMime)) {
968
- headers.set("Content-Type", "application/octet-stream");
969
- headers.set("Content-Disposition", "attachment");
970
- } else {
971
- headers.set("Content-Type", originalMime);
882
+
883
+ // src/shared/utils/media-utils.ts
884
+ var MEDIA_URL_PATTERN = /\/api\/media\/([^?#]+)/;
885
+ function extractMediaKey(mediaUrl, cdnUrl) {
886
+ const urlStr = String(mediaUrl);
887
+ if (cdnUrl) {
888
+ const normalizedCdn = cdnUrl.replace(/\/$/, "");
889
+ if (urlStr.startsWith(normalizedCdn)) {
890
+ const keyPart = urlStr.slice(normalizedCdn.length).replace(/^\//, "");
891
+ const keyWithoutQuery = keyPart.split(/[?#]/)[0];
892
+ return keyWithoutQuery ? decodeURIComponent(keyWithoutQuery) : null;
972
893
  }
973
- headers.set("Content-Security-Policy", "default-src 'none'; sandbox");
974
- headers.set("X-Content-Type-Options", "nosniff");
975
- headers.set("Cache-Control", "public, max-age=31536000, immutable");
976
- return new Response(object.body, { status: 200, headers });
977
- } catch (err) {
978
- console.error(`[serveMediaHandler] Error serving file ${key}:`, err);
979
- return new Response("Internal error", { status: 500 });
980
894
  }
981
- }
982
-
983
- // src/media-utils.ts
984
- var MEDIA_URL_PATTERN = /\/api\/media\/([^/?#]+)/;
985
- function extractMediaKey(mediaUrl) {
986
- const match = MEDIA_URL_PATTERN.exec(String(mediaUrl));
895
+ const match = MEDIA_URL_PATTERN.exec(urlStr);
987
896
  return match ? decodeURIComponent(match[1]) : null;
988
897
  }
989
- function collectMediaKeysRecursive(value, collectedKeys) {
898
+ function collectMediaKeysRecursive(value, collectedKeys, cdnUrl) {
990
899
  if (typeof value === "string") {
991
- const r2Key = extractMediaKey(value);
900
+ const r2Key = extractMediaKey(value, cdnUrl);
992
901
  if (r2Key) {
993
902
  collectedKeys.add(r2Key);
994
903
  return;
@@ -996,29 +905,29 @@ function collectMediaKeysRecursive(value, collectedKeys) {
996
905
  try {
997
906
  const parsed = JSON.parse(value);
998
907
  if (parsed !== value) {
999
- collectMediaKeysRecursive(parsed, collectedKeys);
908
+ collectMediaKeysRecursive(parsed, collectedKeys, cdnUrl);
1000
909
  }
1001
910
  } catch {
1002
911
  }
1003
912
  return;
1004
913
  }
1005
914
  if (Array.isArray(value)) {
1006
- for (const item of value) collectMediaKeysRecursive(item, collectedKeys);
915
+ for (const item of value) collectMediaKeysRecursive(item, collectedKeys, cdnUrl);
1007
916
  return;
1008
917
  }
1009
918
  if (value != null && typeof value === "object") {
1010
919
  for (const nestedValue of Object.values(value)) {
1011
- collectMediaKeysRecursive(nestedValue, collectedKeys);
920
+ collectMediaKeysRecursive(nestedValue, collectedKeys, cdnUrl);
1012
921
  }
1013
922
  }
1014
923
  }
1015
- function extractMediaKeysFromData(seed, entryData) {
924
+ function extractMediaKeysFromData(seed, entryData, cdnUrl) {
1016
925
  const r2Keys = /* @__PURE__ */ new Set();
1017
926
  for (const branch of seed.branches) {
1018
927
  if (branch.type !== "file" && branch.type !== "json") continue;
1019
928
  const fieldValue = entryData[branch.alias];
1020
929
  if (fieldValue == null) continue;
1021
- collectMediaKeysRecursive(fieldValue, r2Keys);
930
+ collectMediaKeysRecursive(fieldValue, r2Keys, cdnUrl);
1022
931
  }
1023
932
  return [...r2Keys];
1024
933
  }
@@ -1052,7 +961,8 @@ async function deleteHandler(context) {
1052
961
  const title = row.title || row.name || entryId;
1053
962
  logContentActivity(context, "delete", entryId, schemaSlug, String(title));
1054
963
  dispatchContentAutomation(context, schemaSlug, "delete", { ...row, id: entryId });
1055
- const r2ObjectKeys = extractMediaKeysFromData(seed, row);
964
+ const cdnUrl = context.env.MEDIA_CDN_URL;
965
+ const r2ObjectKeys = extractMediaKeysFromData(seed, row, cdnUrl);
1056
966
  if (r2ObjectKeys.length > 0) {
1057
967
  await deleteR2Objects(context, r2ObjectKeys).catch((error) => {
1058
968
  if (context.env.ENV !== "production") {
@@ -1214,7 +1124,7 @@ async function bulkHandler(context) {
1214
1124
  }
1215
1125
  }
1216
1126
  const repository = context.get("repository");
1217
- const { updated, failed } = await repository.bulkUpdate(slug, ids, resolvedFields);
1127
+ const { updated, failed } = await repository.bulkUpdate(seed, ids, resolvedFields);
1218
1128
  const jwtPayload = context.get("jwtPayload");
1219
1129
  context.get("activityLogger").log({
1220
1130
  action: "bulk_update",
@@ -1239,8 +1149,160 @@ async function bulkHandler(context) {
1239
1149
  return context.json({ updated, failed: failedProblems });
1240
1150
  }
1241
1151
 
1152
+ // src/features/content/handlers/kanban-position.ts
1153
+ import { resolveKanbanConfig as resolveKanbanConfig2, EntryNotFoundError as EntryNotFoundError3 } from "@beechcms/core";
1154
+ async function kanbanPositionHandler(context) {
1155
+ const slug = context.req.param("slug");
1156
+ const id = context.req.param("id");
1157
+ if (!slug || !id) {
1158
+ return publicProblem(context, { type: "content-invalid-slug-or-id", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID });
1159
+ }
1160
+ const seed = context.get("getSeed")(slug);
1161
+ if (!seed) {
1162
+ return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
1163
+ }
1164
+ let body;
1165
+ try {
1166
+ body = await context.req.json();
1167
+ } catch {
1168
+ return publicProblem(context, { type: "content-invalid-body", title: "Bad Request", status: 400, detail: "Invalid JSON body" });
1169
+ }
1170
+ const { position, axisBranchId } = body;
1171
+ if (typeof position !== "string" || !position || typeof axisBranchId !== "string" || !axisBranchId) {
1172
+ return publicProblem(context, { type: "content-invalid-body", title: "Bad Request", status: 400, detail: "position and axisBranchId are required strings" });
1173
+ }
1174
+ const compat = resolveKanbanConfig2(seed);
1175
+ if (!compat.compatible || !compat.candidates.some((c) => c.branchId === axisBranchId)) {
1176
+ return publicProblem(context, { type: "content-invalid-kanban-axis", title: "Bad Request", status: 400, detail: "axisBranchId is not a valid kanban candidate for this seed" });
1177
+ }
1178
+ try {
1179
+ await context.get("repository").findById(seed, id);
1180
+ } catch (error) {
1181
+ if (error instanceof EntryNotFoundError3) {
1182
+ return publicProblem(context, { type: "content-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.NOT_FOUND });
1183
+ }
1184
+ throw error;
1185
+ }
1186
+ await context.get("kanbanPositionRepository").setPosition(slug, id, axisBranchId, position);
1187
+ return context.json({ success: true });
1188
+ }
1189
+
1190
+ // src/features/content/handlers/kanban-move.ts
1191
+ import {
1192
+ resolveKanbanConfig as resolveKanbanConfig3,
1193
+ validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload3
1194
+ } from "@beechcms/core";
1195
+ async function kanbanMoveHandler(context) {
1196
+ const slug = context.req.param("slug");
1197
+ const id = context.req.param("id");
1198
+ if (!slug || !id) {
1199
+ return publicProblem(context, { type: "content-invalid-slug-or-id", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID });
1200
+ }
1201
+ const seed = context.get("getSeed")(slug);
1202
+ if (!seed) {
1203
+ return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
1204
+ }
1205
+ let body;
1206
+ try {
1207
+ const raw = await context.req.json();
1208
+ if (typeof raw.position !== "string" || !raw.position || typeof raw.axisBranchId !== "string" || !raw.axisBranchId) {
1209
+ throw new Error("invalid");
1210
+ }
1211
+ body = raw;
1212
+ } catch {
1213
+ return publicProblem(context, { type: "content-invalid-body", title: "Bad Request", status: 400, detail: "position and axisBranchId are required strings" });
1214
+ }
1215
+ const compat = resolveKanbanConfig3(seed);
1216
+ const candidate = compat.candidates.find((c) => c.branchId === body.axisBranchId);
1217
+ if (!compat.compatible || !candidate) {
1218
+ return publicProblem(context, { type: "content-invalid-kanban-axis", title: "Bad Request", status: 400, detail: "axisBranchId is not a valid kanban candidate for this seed" });
1219
+ }
1220
+ const axisBranch = seed.branches.find((b) => b.id === body.axisBranchId);
1221
+ const repository = context.get("repository");
1222
+ let current;
1223
+ try {
1224
+ current = await repository.findById(seed, id);
1225
+ } catch {
1226
+ return publicProblem(context, { type: "content-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.NOT_FOUND });
1227
+ }
1228
+ let patch = null;
1229
+ if (body.axis) {
1230
+ if (body.axis.kind === "scalar") {
1231
+ const rawValue = body.axis.value;
1232
+ const coerced = axisBranch.type === "boolean" && rawValue !== null ? rawValue === "true" : rawValue;
1233
+ patch = { [axisBranch.alias]: coerced };
1234
+ } else {
1235
+ const currentTags = current[axisBranch.alias] ?? [];
1236
+ const { oldValue, newValue } = body.axis;
1237
+ const withoutOld = oldValue !== null ? currentTags.filter((t) => t !== oldValue) : currentTags;
1238
+ const nextTags = newValue !== null && !withoutOld.includes(newValue) ? [...withoutOld, newValue] : withoutOld;
1239
+ patch = { [axisBranch.alias]: nextTags };
1240
+ }
1241
+ const validation = validateAndSanitizeSeedPayload3(seed, patch, {
1242
+ operation: "update",
1243
+ allowNull: true,
1244
+ requireAtLeastOneValidField: true,
1245
+ enforceRequiredFields: false,
1246
+ idGenerator: context.get("idGenerator")
1247
+ });
1248
+ if (validation.details.length > 0) {
1249
+ return publicProblem(context, { type: "content-validation-error", title: "Unprocessable Entity", status: 422, detail: validation.details[0].message });
1250
+ }
1251
+ patch = validation.data;
1252
+ }
1253
+ const jwtPayload = context.get("jwtPayload");
1254
+ await repository.updateWithKanbanPosition(seed, id, patch, body.position, body.axisBranchId, { actor: jwtPayload.sub });
1255
+ return context.json({ success: true });
1256
+ }
1257
+
1258
+ // src/features/content/handlers/view-config.ts
1259
+ import { seedViewConfigSchema, validateCardConfigAgainstSeed } from "@beechcms/core";
1260
+ async function getViewConfigHandler(context) {
1261
+ const slug = context.req.param("slug");
1262
+ if (!slug) {
1263
+ return publicProblem(context, { type: "content-invalid-slug", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG });
1264
+ }
1265
+ const seed = context.get("getSeed")(slug);
1266
+ if (!seed) {
1267
+ return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
1268
+ }
1269
+ const config = await context.get("seedLayoutRepository").getViewConfig(slug);
1270
+ return context.json(config ?? {});
1271
+ }
1272
+ async function putViewConfigHandler(context) {
1273
+ const slug = context.req.param("slug");
1274
+ if (!slug) {
1275
+ return publicProblem(context, { type: "content-invalid-slug", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG });
1276
+ }
1277
+ const seed = context.get("getSeed")(slug);
1278
+ if (!seed) {
1279
+ return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
1280
+ }
1281
+ let body;
1282
+ try {
1283
+ body = await context.req.json();
1284
+ } catch {
1285
+ return publicProblem(context, { type: "content-invalid-body", title: "Bad Request", status: 400, detail: "Invalid JSON body" });
1286
+ }
1287
+ const parsed = seedViewConfigSchema.safeParse(body);
1288
+ if (!parsed.success) {
1289
+ return publicProblem(context, { type: "content-invalid-view-config", title: "Unprocessable Entity", status: 422, detail: parsed.error.issues[0]?.message ?? "Invalid view config" });
1290
+ }
1291
+ if (parsed.data.card) {
1292
+ const { cleaned } = validateCardConfigAgainstSeed(parsed.data.card, seed);
1293
+ parsed.data = { ...parsed.data, card: cleaned };
1294
+ }
1295
+ const jwtPayload = context.get("jwtPayload");
1296
+ await context.get("seedLayoutRepository").setViewConfig(slug, parsed.data, jwtPayload.sub);
1297
+ return context.json({ ok: true });
1298
+ }
1299
+
1242
1300
  // src/features/content/index.ts
1243
- var content = new Hono2();
1301
+ var content = new Hono();
1302
+ content.patch("/:slug/:id/kanban-move", kanbanMoveHandler);
1303
+ content.patch("/:slug/:id/kanban-position", kanbanPositionHandler);
1304
+ content.get("/:slug/view-config", getViewConfigHandler);
1305
+ content.put("/:slug/view-config", putViewConfigHandler);
1244
1306
  content.get("/:slug", listHandler);
1245
1307
  content.get("/:slug/facets", facetsHandler);
1246
1308
  content.get("/:schema_slug/by-slug/:entry_slug", getBySlugHandler);
@@ -1251,10 +1313,10 @@ content.put("/:slug/:id", updateHandler);
1251
1313
  content.delete("/:slug/:id", deleteHandler);
1252
1314
  var content_default = content;
1253
1315
 
1254
- // src/widget.ts
1255
- import { Hono as Hono3 } from "hono";
1316
+ // src/features/widget/widget.ts
1317
+ import { Hono as Hono2 } from "hono";
1256
1318
  import { deserializeFromDb } from "@beechcms/core";
1257
- var widgetApp = new Hono3();
1319
+ var widgetApp = new Hono2();
1258
1320
  var DEFAULT_LEADERBOARD_LIMIT = 10;
1259
1321
  var MAXIMUM_LEADERBOARD_LIMIT = 100;
1260
1322
  var DEFAULT_LIST_LIMIT = 25;
@@ -1445,8 +1507,8 @@ widgetApp.get("/distribution/:seed", async (context) => {
1445
1507
  });
1446
1508
 
1447
1509
  // src/features/rotate-field/rotate-field.handler.ts
1448
- import { Hono as Hono4 } from "hono";
1449
- import { resolvePolicies as resolvePolicies4, verifyHashField, sha256hex as sha256hex2, validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload3, EntryNotFoundError as EntryNotFoundError3 } from "@beechcms/core";
1510
+ import { Hono as Hono3 } from "hono";
1511
+ import { resolvePolicies as resolvePolicies4, verifyHashField, sha256hex as sha256hex2, validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload4, EntryNotFoundError as EntryNotFoundError4 } from "@beechcms/core";
1450
1512
 
1451
1513
  // src/features/rotate-field/rotate-field.schema.ts
1452
1514
  import { z } from "zod";
@@ -1457,7 +1519,7 @@ var rotateFieldRequestSchema = z.object({
1457
1519
  });
1458
1520
 
1459
1521
  // src/features/rotate-field/rotate-field.handler.ts
1460
- var rotateFieldApp = new Hono4();
1522
+ var rotateFieldApp = new Hono3();
1461
1523
  rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
1462
1524
  const seedSlug = context.req.param("slug");
1463
1525
  const entryId = context.req.param("id");
@@ -1513,7 +1575,7 @@ rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
1513
1575
  try {
1514
1576
  contentRecord = await context.get("repository").findById(seed, entryId);
1515
1577
  } catch (error) {
1516
- if (error instanceof EntryNotFoundError3) {
1578
+ if (error instanceof EntryNotFoundError4) {
1517
1579
  return publicProblem(context, {
1518
1580
  type: "content-not-found",
1519
1581
  title: "Not Found",
@@ -1541,7 +1603,7 @@ rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
1541
1603
  detail: "Current value does not match stored value"
1542
1604
  });
1543
1605
  }
1544
- const fieldValidationResult = validateAndSanitizeSeedPayload3(
1606
+ const fieldValidationResult = validateAndSanitizeSeedPayload4(
1545
1607
  seed,
1546
1608
  { [fieldAlias]: nextValue },
1547
1609
  { operation: "update", allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: false }
@@ -1560,9 +1622,9 @@ rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
1560
1622
  });
1561
1623
 
1562
1624
  // src/features/password-reset/index.ts
1563
- import { Hono as Hono5 } from "hono";
1625
+ import { Hono as Hono4 } from "hono";
1564
1626
 
1565
- // src/features/email/providers/resend.ts
1627
+ // src/shared/email/providers/resend.ts
1566
1628
  var RESEND_API_URL = "https://api.resend.com/emails";
1567
1629
  var ResendEmailProvider = class {
1568
1630
  apiKey;
@@ -1598,7 +1660,7 @@ var ResendEmailProvider = class {
1598
1660
  }
1599
1661
  };
1600
1662
 
1601
- // src/features/email/providers/smtp.ts
1663
+ // src/shared/email/providers/smtp.ts
1602
1664
  function parseAddress(raw) {
1603
1665
  const match = raw.match(/^\s*(.+?)\s*<([^>]+)>\s*$/);
1604
1666
  if (match) return { Name: match[1], Email: match[2] };
@@ -1628,7 +1690,7 @@ var SmtpEmailProvider = class {
1628
1690
  }
1629
1691
  };
1630
1692
 
1631
- // src/features/email/templates/shell.ts
1693
+ // src/shared/email/templates/shell.ts
1632
1694
  function buildEmailShell(locale, slots) {
1633
1695
  const ctaBlock = slots.cta ? `<a href="${slots.cta.href}"
1634
1696
  style="display:inline-block;background:#111;color:#fff;padding:12px 24px;
@@ -1656,7 +1718,7 @@ function buildEmailShell(locale, slots) {
1656
1718
  </html>`;
1657
1719
  }
1658
1720
 
1659
- // src/features/email/templates/password-reset.ts
1721
+ // src/shared/email/templates/password-reset.ts
1660
1722
  var COPY = {
1661
1723
  en: {
1662
1724
  subject: "Reset your Beech CMS password",
@@ -1686,7 +1748,7 @@ function buildPasswordResetEmail(resetUrl, locale) {
1686
1748
  };
1687
1749
  }
1688
1750
 
1689
- // src/features/email/templates/password-changed.ts
1751
+ // src/shared/email/templates/password-changed.ts
1690
1752
  var COPY2 = {
1691
1753
  en: {
1692
1754
  subject: "Your Beech CMS password has been changed",
@@ -1716,7 +1778,7 @@ function buildPasswordChangedEmail(locale) {
1716
1778
  };
1717
1779
  }
1718
1780
 
1719
- // src/features/email/templates/automation-mail.ts
1781
+ // src/shared/email/templates/automation-mail.ts
1720
1782
  function buildAutomationEmail(params) {
1721
1783
  return {
1722
1784
  to: params.to,
@@ -1729,7 +1791,7 @@ function stripHtml(input) {
1729
1791
  return input.replace(/<[^>]+>/g, "").trim();
1730
1792
  }
1731
1793
 
1732
- // src/features/email/email.service.ts
1794
+ // src/shared/email/email.service.ts
1733
1795
  var DEFAULT_FROM = "Beech CMS <onboarding@resend.dev>";
1734
1796
  function createProvider(env) {
1735
1797
  if (env.provider === "smtp") {
@@ -1771,7 +1833,7 @@ async function sendPasswordChangedEmail(params) {
1771
1833
  async function sendAutomationMail(params) {
1772
1834
  const provider = createProvider({
1773
1835
  provider: params.provider,
1774
- apiKey: params.apiKey ?? params.resendApiKey,
1836
+ apiKey: params.apiKey ?? "",
1775
1837
  smtpBaseUrl: params.smtpBaseUrl,
1776
1838
  isDev: false
1777
1839
  });
@@ -1784,7 +1846,7 @@ async function sendAutomationMail(params) {
1784
1846
  });
1785
1847
  }
1786
1848
 
1787
- // src/features/email/email.types.ts
1849
+ // src/shared/email/email.types.ts
1788
1850
  var SUPPORTED_EMAIL_LOCALES = ["en", "it"];
1789
1851
  function resolveEmailLocale(raw) {
1790
1852
  if (typeof raw === "string" && SUPPORTED_EMAIL_LOCALES.includes(raw)) {
@@ -1817,7 +1879,11 @@ async function requestPasswordReset(context) {
1817
1879
  const clientIpAddress = getClientIp(req);
1818
1880
  const forgotPasswordRateLimit = await context.get("rateLimiters").getLimiter("forgotPassword").checkLimit(clientIpAddress);
1819
1881
  if (!forgotPasswordRateLimit.isAllowed) {
1820
- return context.json({ error: "Too many requests" }, 429);
1882
+ const headers = {};
1883
+ if (forgotPasswordRateLimit.retryAfterSeconds !== void 0) {
1884
+ headers["Retry-After"] = String(forgotPasswordRateLimit.retryAfterSeconds);
1885
+ }
1886
+ return context.json({ error: "Too many requests" }, 429, headers);
1821
1887
  }
1822
1888
  const registeredUser = await context.get("userRepository").findByEmail(normalizedEmail);
1823
1889
  if (!registeredUser) {
@@ -1859,6 +1925,7 @@ async function requestPasswordReset(context) {
1859
1925
  import { sha256hex as sha256hex4 } from "@beechcms/core";
1860
1926
  var MIN_PASSWORD_LENGTH2 = 8;
1861
1927
  var MAX_PASSWORD_LENGTH2 = 128;
1928
+ var MAX_PASSWORD_BYTES2 = 72;
1862
1929
  async function resetPassword(context) {
1863
1930
  const { env, req, executionCtx } = context;
1864
1931
  const useSmtp = env.EMAIL_PROVIDER === "smtp";
@@ -1868,7 +1935,11 @@ async function resetPassword(context) {
1868
1935
  const clientIpAddress = getClientIp(req);
1869
1936
  const resetPasswordRateLimit = await context.get("rateLimiters").getLimiter("resetPassword").checkLimit(clientIpAddress);
1870
1937
  if (!resetPasswordRateLimit.isAllowed) {
1871
- return context.json({ error: "Too many requests" }, 429);
1938
+ const headers = {};
1939
+ if (resetPasswordRateLimit.retryAfterSeconds !== void 0) {
1940
+ headers["Retry-After"] = String(resetPasswordRateLimit.retryAfterSeconds);
1941
+ }
1942
+ return context.json({ error: "Too many requests" }, 429, headers);
1872
1943
  }
1873
1944
  let payload;
1874
1945
  try {
@@ -1887,6 +1958,11 @@ async function resetPassword(context) {
1887
1958
  error: `Password must be between ${MIN_PASSWORD_LENGTH2} and ${MAX_PASSWORD_LENGTH2} characters`
1888
1959
  }, 400);
1889
1960
  }
1961
+ if (new TextEncoder().encode(newPassword).length > MAX_PASSWORD_BYTES2) {
1962
+ return context.json({
1963
+ error: `Password must not exceed ${MAX_PASSWORD_BYTES2} bytes when UTF-8 encoded`
1964
+ }, 400);
1965
+ }
1890
1966
  const emailLocale = resolveEmailLocale(payload.locale);
1891
1967
  const tokenHash = await sha256hex4(resetToken);
1892
1968
  const nowTimestamp = Math.floor(Date.now() / 1e3);
@@ -1925,7 +2001,7 @@ async function resetPassword(context) {
1925
2001
  }
1926
2002
 
1927
2003
  // src/features/password-reset/index.ts
1928
- var passwordResetApp = new Hono5();
2004
+ var passwordResetApp = new Hono4();
1929
2005
  passwordResetApp.get("/auth/features", (context) => {
1930
2006
  return context.json({ passwordReset: context.env.EMAIL_PROVIDER === "smtp" || Boolean(context.env.RESEND_API_KEY) });
1931
2007
  });
@@ -1933,8 +2009,8 @@ passwordResetApp.post("/auth/forgot-password", requestPasswordReset);
1933
2009
  passwordResetApp.post("/auth/reset-password", resetPassword);
1934
2010
 
1935
2011
  // src/features/setup/index.ts
1936
- import { Hono as Hono6 } from "hono";
1937
- var setupApp = new Hono6();
2012
+ import { Hono as Hono5 } from "hono";
2013
+ var setupApp = new Hono5();
1938
2014
  setupApp.get("/auth/setup", async (context) => {
1939
2015
  const userCount = await context.get("userRepository").countAll();
1940
2016
  const needsSetup = userCount === 0;
@@ -1996,6 +2072,14 @@ setupApp.post("/auth/setup", async (context) => {
1996
2072
  detail: "Password must be between 8 and 128 characters long."
1997
2073
  });
1998
2074
  }
2075
+ if (new TextEncoder().encode(password).length > 72) {
2076
+ return publicProblem(context, {
2077
+ type: "validation-error",
2078
+ title: "Invalid password",
2079
+ status: 422,
2080
+ detail: "Password must not exceed 72 bytes when UTF-8 encoded."
2081
+ });
2082
+ }
1999
2083
  if (!settings || typeof settings !== "object") {
2000
2084
  return publicProblem(context, {
2001
2085
  type: "validation-error",
@@ -2185,16 +2269,16 @@ setupApp.post("/auth/setup", async (context) => {
2185
2269
  });
2186
2270
 
2187
2271
  // src/features/draft/draft.handler.ts
2188
- import { Hono as Hono7 } from "hono";
2272
+ import { Hono as Hono6 } from "hono";
2189
2273
  import {
2190
- validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload4,
2274
+ validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload5,
2191
2275
  resolvePolicies as resolvePolicies5,
2192
2276
  RelationTargetNotFoundError
2193
2277
  } from "@beechcms/core";
2194
2278
 
2195
2279
  // src/features/draft/draft.middleware.ts
2196
2280
  import { createMiddleware } from "hono/factory";
2197
- import { EntryNotFoundError as EntryNotFoundError4 } from "@beechcms/core";
2281
+ import { EntryNotFoundError as EntryNotFoundError5 } from "@beechcms/core";
2198
2282
  var draftGuard = createMiddleware(async (context, next) => {
2199
2283
  const slug = context.req.param("slug");
2200
2284
  const id = context.req.param("id");
@@ -2227,7 +2311,7 @@ var draftGuard = createMiddleware(async (context, next) => {
2227
2311
  try {
2228
2312
  await repository.findById(seed, id);
2229
2313
  } catch (error) {
2230
- if (error instanceof EntryNotFoundError4) {
2314
+ if (error instanceof EntryNotFoundError5) {
2231
2315
  return publicProblem(context, {
2232
2316
  type: "content-not-found",
2233
2317
  title: "Not Found",
@@ -2241,7 +2325,7 @@ var draftGuard = createMiddleware(async (context, next) => {
2241
2325
  });
2242
2326
 
2243
2327
  // src/features/draft/draft.handler.ts
2244
- var draftApp = new Hono7();
2328
+ var draftApp = new Hono6();
2245
2329
  draftApp.get("/drafts", async (context) => {
2246
2330
  const seeds = context.get("seedRegistry").draftEnabled();
2247
2331
  const repository = context.get("repository");
@@ -2293,7 +2377,7 @@ draftApp.put("/:slug/:id/draft", draftGuard, async (context) => {
2293
2377
  detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(", ")}`
2294
2378
  });
2295
2379
  }
2296
- const validation = validateAndSanitizeSeedPayload4(seed, body, {
2380
+ const validation = validateAndSanitizeSeedPayload5(seed, body, {
2297
2381
  operation: "update",
2298
2382
  allowNull: true,
2299
2383
  requireAtLeastOneValidField: true,
@@ -2381,12 +2465,13 @@ draftApp.delete("/:slug/:id/draft", draftGuard, async (context) => {
2381
2465
  });
2382
2466
 
2383
2467
  // src/features/settings/settings.handler.ts
2384
- import { Hono as Hono8 } from "hono";
2468
+ import { Hono as Hono7 } from "hono";
2385
2469
  import { sha256hex as sha256hex5 } from "@beechcms/core";
2386
- var settingsApp = new Hono8();
2470
+ var settingsApp = new Hono7();
2387
2471
  var EMAIL_VALIDATION_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2388
2472
  var MIN_PASSWORD_LENGTH3 = 8;
2389
2473
  var MAX_PASSWORD_LENGTH3 = 128;
2474
+ var MAX_PASSWORD_BYTES3 = 72;
2390
2475
  var SESSION_LIST_LIMIT = 20;
2391
2476
  var ACTIVITY_LOG_LIMIT = 30;
2392
2477
  settingsApp.get("/", async (context) => {
@@ -2549,6 +2634,9 @@ settingsApp.put("/password", async (context) => {
2549
2634
  if (newPassword.length > MAX_PASSWORD_LENGTH3) {
2550
2635
  return context.json({ error: "Password is too long" }, 400);
2551
2636
  }
2637
+ if (new TextEncoder().encode(newPassword).length > MAX_PASSWORD_BYTES3) {
2638
+ return context.json({ error: `Password must not exceed ${MAX_PASSWORD_BYTES3} bytes when UTF-8 encoded` }, 400);
2639
+ }
2552
2640
  const userRecord = await context.get("userRepository").findById(userId);
2553
2641
  if (!userRecord) {
2554
2642
  return context.json({ error: "User not found" }, 404);
@@ -2659,7 +2747,7 @@ settingsApp.put("/notifications", async (context) => {
2659
2747
  });
2660
2748
 
2661
2749
  // src/features/schema/schema.handler.ts
2662
- import { Hono as Hono9 } from "hono";
2750
+ import { Hono as Hono8 } from "hono";
2663
2751
  import { canEditLayout, formLayoutSchema, validateLayoutAgainstSeed } from "@beechcms/core";
2664
2752
  function requireLayoutEditPermission(context) {
2665
2753
  const role = context.get("jwtPayload")?.role;
@@ -2688,7 +2776,7 @@ function requireSeedBySlug(context, slug) {
2688
2776
  }
2689
2777
  return { seed, error: null };
2690
2778
  }
2691
- var schemaApp = new Hono9();
2779
+ var schemaApp = new Hono8();
2692
2780
  schemaApp.get("/", async (context) => {
2693
2781
  const registry = context.get("seedRegistry");
2694
2782
  const layouts = await context.get("seedLayoutRepository").getAllAsMap();
@@ -2750,7 +2838,7 @@ schemaApp.delete("/:slug/layout", async (context) => {
2750
2838
  });
2751
2839
 
2752
2840
  // src/features/dashboard-layout/dashboard-layout.handler.ts
2753
- import { Hono as Hono10 } from "hono";
2841
+ import { Hono as Hono9 } from "hono";
2754
2842
  import {
2755
2843
  canEditDashboard,
2756
2844
  dashboardLayoutSchema,
@@ -2810,7 +2898,7 @@ async function putLayout(context, scope) {
2810
2898
  await context.get("dashboardLayoutRepository").upsert(scope, result.cleaned, userId);
2811
2899
  return context.json({ ok: true, layout: result.cleaned, warnings: result.warnings });
2812
2900
  }
2813
- var dashboardLayoutApp = new Hono10();
2901
+ var dashboardLayoutApp = new Hono9();
2814
2902
  dashboardLayoutApp.get("/", async (context) => {
2815
2903
  const role = context.get("jwtPayload")?.role;
2816
2904
  const chain = resolveDashboardScopeChain(role);
@@ -2885,7 +2973,7 @@ dashboardLayoutApp.delete("/:scope", async (context) => {
2885
2973
  });
2886
2974
 
2887
2975
  // src/features/seeds/seeds.handler.ts
2888
- import { Hono as Hono11 } from "hono";
2976
+ import { Hono as Hono10 } from "hono";
2889
2977
  import {
2890
2978
  nextBranchId,
2891
2979
  validateSeedDefinitions,
@@ -3028,16 +3116,17 @@ async function deleteSeedMediaObjects(context, slug, seed, schemaMutator) {
3028
3116
  try {
3029
3117
  const dbCols = await schemaMutator.getColumns(`content_${slug}`);
3030
3118
  if (!dbCols) return;
3031
- const colList = fileBranches.map((b) => b.alias).filter((a) => dbCols.has(a)).join(", ");
3032
- if (!colList) return;
3033
- const rs = await context.env.DB.prepare(`SELECT ${colList} FROM content_${slug}`).all();
3034
- const rows = rs.results ?? [];
3119
+ const validCols = fileBranches.map((b) => b.alias).filter((a) => dbCols.has(a));
3120
+ if (validCols.length === 0) return;
3121
+ const sql = `SELECT ${validCols.join(", ")} FROM content_${slug}`;
3122
+ const { results: rows } = await context.env.DB.prepare(sql).all();
3123
+ const cdnUrl = context.env.MEDIA_CDN_URL;
3035
3124
  const r2Keys = [];
3036
3125
  for (const row of rows) {
3037
3126
  for (const b of fileBranches) {
3038
3127
  const val = row[b.alias];
3039
3128
  if (!val) continue;
3040
- const keys = extractMediaKeysFromData(seed, { [b.alias]: val });
3129
+ const keys = extractMediaKeysFromData(seed, { [b.alias]: val }, cdnUrl);
3041
3130
  r2Keys.push(...keys);
3042
3131
  }
3043
3132
  }
@@ -3048,7 +3137,7 @@ async function deleteSeedMediaObjects(context, slug, seed, schemaMutator) {
3048
3137
  } catch {
3049
3138
  }
3050
3139
  }
3051
- var seedsApp = new Hono11();
3140
+ var seedsApp = new Hono10();
3052
3141
  seedsApp.use("*", async (context, next) => {
3053
3142
  const denied = requireAdmin(context);
3054
3143
  if (denied) return denied;
@@ -3323,8 +3412,8 @@ seedsApp.patch("/:slug/branches/:branchId/retype", async (context) => {
3323
3412
  });
3324
3413
 
3325
3414
  // src/features/notifications/notifications.handler.ts
3326
- import { Hono as Hono12 } from "hono";
3327
- var notificationsApp = new Hono12();
3415
+ import { Hono as Hono11 } from "hono";
3416
+ var notificationsApp = new Hono11();
3328
3417
  var NOTIFICATION_LIST_LIMIT = 50;
3329
3418
  notificationsApp.get("/notifications", async (context) => {
3330
3419
  try {
@@ -3384,7 +3473,7 @@ notificationsApp.post("/notifications/mark-all-read", async (context) => {
3384
3473
  }
3385
3474
  });
3386
3475
 
3387
- // src/features/automations/template-grammar.ts
3476
+ // src/features/automations/evaluator/template-grammar.ts
3388
3477
  import { AUTOMATION_RESERVED_WORDS } from "@beechcms/core";
3389
3478
  var SLUG_RE2 = /^[a-zA-Z0-9_-]+$/;
3390
3479
  var AGGREGATE_OPS = /* @__PURE__ */ new Set(["count", "sum", "avg", "min", "max", "pluck"]);
@@ -3517,7 +3606,7 @@ function parseTemplateKey(raw) {
3517
3606
  return { kind: "var_access", name: nameToken, steps, conditions };
3518
3607
  }
3519
3608
 
3520
- // src/features/automations/automation-runner.utils.ts
3609
+ // src/features/automations/engine/automation-runner.utils.ts
3521
3610
  function interpolate(template, context, defaultValue = "", onMissing) {
3522
3611
  if (!template) return "";
3523
3612
  const replacer = (_, key) => {
@@ -3551,7 +3640,7 @@ function resolvePath(obj, path) {
3551
3640
  return current;
3552
3641
  }
3553
3642
 
3554
- // src/features/automations/context-resolver.ts
3643
+ // src/features/automations/evaluator/context-resolver.ts
3555
3644
  var MAX_PLUCK = 100;
3556
3645
  function applyAggregate(op, field, rows, onMissing) {
3557
3646
  switch (op) {
@@ -3666,7 +3755,7 @@ async function resolveAutomationContext(_automation, triggerEntry, batchEntries)
3666
3755
  return { lookup, triggerEntry };
3667
3756
  }
3668
3757
 
3669
- // src/features/automations/action-executors/webhook.executor.ts
3758
+ // src/features/automations/executors/webhook.executor.ts
3670
3759
  import { signWebhookBody } from "@beechcms/core/webhook-crypto";
3671
3760
  var WEBHOOK_TIMEOUT_MS = 8e3;
3672
3761
  var warnedNoSecret = false;
@@ -3702,7 +3791,7 @@ async function executeWebhook(action, context, env) {
3702
3791
  }
3703
3792
  }
3704
3793
 
3705
- // src/features/automations/action-executors/send-mail.executor.ts
3794
+ // src/features/automations/executors/send-mail.executor.ts
3706
3795
  async function executeSendMail(action, context, env) {
3707
3796
  const missingFields = [];
3708
3797
  const logMissing = (field) => missingFields.push(field);
@@ -3735,7 +3824,7 @@ async function executeSendMail(action, context, env) {
3735
3824
  }
3736
3825
  }
3737
3826
 
3738
- // src/features/automations/action-executors/edit-field.executor.ts
3827
+ // src/features/automations/executors/edit-field.executor.ts
3739
3828
  async function executeEditField(action, entry, context, repository, seed) {
3740
3829
  const id = entry.id;
3741
3830
  if (typeof id !== "string") {
@@ -3745,7 +3834,7 @@ async function executeEditField(action, entry, context, repository, seed) {
3745
3834
  await repository.update(seed, id, { [action.field]: resolved });
3746
3835
  }
3747
3836
 
3748
- // src/features/automations/action-executors/create-entry.executor.ts
3837
+ // src/features/automations/executors/create-entry.executor.ts
3749
3838
  async function executeCreateEntry(action, entry, repository, getSeed, idGenerator) {
3750
3839
  const targetSeed = getSeed(action.seed_slug);
3751
3840
  if (!targetSeed) throw new Error(`create_entry: unknown seed ${action.seed_slug}`);
@@ -3757,7 +3846,7 @@ async function executeCreateEntry(action, entry, repository, getSeed, idGenerato
3757
3846
  await repository.create(targetSeed, newId, newId, "draft", payload);
3758
3847
  }
3759
3848
 
3760
- // src/features/automations/filter-translation.ts
3849
+ // src/features/automations/filters/filter-translation.ts
3761
3850
  var SYSTEM_COLUMNS = /* @__PURE__ */ new Set(["id", "slug", "status", "created_at", "updated_at"]);
3762
3851
  function mapBranchTypeToFilterType(type) {
3763
3852
  switch (type) {
@@ -3795,7 +3884,7 @@ function conditionToFilterGroup(c, seed) {
3795
3884
  };
3796
3885
  }
3797
3886
 
3798
- // src/features/automations/action-executors/set-variable.executor.ts
3887
+ // src/features/automations/executors/set-variable.executor.ts
3799
3888
  async function executeSetVariable(action, ctx) {
3800
3889
  const seedSlug = action.seed_slug ?? ctx.seed.slug;
3801
3890
  const targetSeed = ctx.getSeed(seedSlug);
@@ -3908,7 +3997,7 @@ function calculateGeneralStats(seed, items) {
3908
3997
  return { count: items.length, sum, avg, min, max, pluck };
3909
3998
  }
3910
3999
 
3911
- // src/features/automations/action-executors/index.ts
4000
+ // src/features/automations/executors/index.ts
3912
4001
  async function executeAction(action, ctx) {
3913
4002
  switch (action.type) {
3914
4003
  case "set_variable":
@@ -3928,7 +4017,7 @@ async function executeAction(action, ctx) {
3928
4017
  }
3929
4018
  }
3930
4019
 
3931
- // src/features/automations/when-evaluator.ts
4020
+ // src/features/automations/filters/when-evaluator.ts
3932
4021
  function evaluateWhen(node, context) {
3933
4022
  if (!node) return true;
3934
4023
  if (node.kind === "predicate") return evalPredicate(node, context);
@@ -4008,7 +4097,7 @@ function coerceEqual(a, b) {
4008
4097
  return a === b;
4009
4098
  }
4010
4099
 
4011
- // src/features/automations/var-access-resolver.ts
4100
+ // src/features/automations/evaluator/var-access-resolver.ts
4012
4101
  function toNumeric(v) {
4013
4102
  const n = Number(v);
4014
4103
  if (Number.isFinite(n)) return n;
@@ -4111,7 +4200,7 @@ function resolveVarAccess(parsed, variables, onMissing) {
4111
4200
  return current;
4112
4201
  }
4113
4202
 
4114
- // src/features/automations/automation-runner.ts
4203
+ // src/features/automations/engine/automation-runner.ts
4115
4204
  function withVariables(base, variables) {
4116
4205
  return {
4117
4206
  triggerEntry: base.triggerEntry,
@@ -4164,10 +4253,10 @@ var AutomationRunner = class {
4164
4253
  }
4165
4254
  };
4166
4255
 
4167
- // src/features/automations/automations.handler.ts
4168
- import { Hono as Hono13 } from "hono";
4256
+ // src/features/automations/api/automations.handler.ts
4257
+ import { Hono as Hono12 } from "hono";
4169
4258
 
4170
- // src/features/automations/automations.schema.ts
4259
+ // src/features/automations/api/automations.schema.ts
4171
4260
  import { z as z2 } from "zod";
4172
4261
  import { isPrivateHost } from "@beechcms/core";
4173
4262
  var whenOperandSchema = z2.union([
@@ -4298,8 +4387,8 @@ var toggleAutomationSchema = z2.object({
4298
4387
  enabled: z2.boolean()
4299
4388
  });
4300
4389
 
4301
- // src/features/automations/automations.handler.ts
4302
- var automationsApp = new Hono13();
4390
+ // src/features/automations/api/automations.handler.ts
4391
+ var automationsApp = new Hono12();
4303
4392
  automationsApp.get("/", async (context) => {
4304
4393
  const seedSlug = context.req.query("seed");
4305
4394
  if (!seedSlug) {
@@ -4445,7 +4534,7 @@ automationsApp.delete("/:id", async (context) => {
4445
4534
  });
4446
4535
 
4447
4536
  // src/features/stats/stats.handler.ts
4448
- import { Hono as Hono14 } from "hono";
4537
+ import { Hono as Hono13 } from "hono";
4449
4538
  import { SystemClock } from "@beechcms/core";
4450
4539
  var DATABASE_ERROR = "Database error";
4451
4540
  var INTERNAL_SERVER_ERROR = "Internal Server Error";
@@ -4467,7 +4556,7 @@ function getMimeType(extension) {
4467
4556
  const type = extension === "jpg" ? "jpeg" : extension || "jpeg";
4468
4557
  return `image/${type}`;
4469
4558
  }
4470
- var statsApp = new Hono14();
4559
+ var statsApp = new Hono13();
4471
4560
  statsApp.get("/stats/media-library", async (context) => {
4472
4561
  try {
4473
4562
  const mediaRepository = context.get("mediaRepository");
@@ -4763,7 +4852,7 @@ statsApp.post("/stats/storage/sync", async (context) => {
4763
4852
  });
4764
4853
 
4765
4854
  // src/features/backrefs/backrefs.handler.ts
4766
- import { Hono as Hono15 } from "hono";
4855
+ import { Hono as Hono14 } from "hono";
4767
4856
  import { resolvePolicies as resolvePolicies6 } from "@beechcms/core";
4768
4857
 
4769
4858
  // src/features/backrefs/d1-backref.repository.ts
@@ -4815,7 +4904,7 @@ var D1BackrefRepository = class {
4815
4904
  var PREVIEW_LIMIT = 3;
4816
4905
  var DEFAULT_PAGE_LIMIT = 20;
4817
4906
  var MAX_PAGE_LIMIT = 100;
4818
- var backrefsApp = new Hono15();
4907
+ var backrefsApp = new Hono14();
4819
4908
  backrefsApp.get("/:targetSlug/:targetId/backrefs", async (c) => {
4820
4909
  const targetSlug = c.req.param("targetSlug");
4821
4910
  const targetId = c.req.param("targetId");
@@ -4931,55 +5020,228 @@ function filterVisibility(items, sourceSeed) {
4931
5020
  displayName: item.displayName ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : null
4932
5021
  }));
4933
5022
  }
4934
- return items;
4935
- }
4936
-
4937
- // src/features/webhooks/index.ts
4938
- import { Hono as Hono16 } from "hono";
4939
- import { Receiver } from "@upstash/qstash";
4940
- var webhooksApp = new Hono16();
4941
- webhooksApp.post("/qstash", async (context) => {
4942
- const currentSigningKey = context.env.QSTASH_CURRENT_SIGNING_KEY;
4943
- const nextSigningKey = context.env.QSTASH_NEXT_SIGNING_KEY;
4944
- if (!currentSigningKey || !nextSigningKey) {
4945
- console.error("QStash webhook called but signing keys are not configured");
4946
- return context.text("Configuration error", 500);
5023
+ return items;
5024
+ }
5025
+
5026
+ // src/features/webhooks/index.ts
5027
+ import { Hono as Hono15 } from "hono";
5028
+ import { Receiver } from "@upstash/qstash";
5029
+ var webhooksApp = new Hono15();
5030
+ webhooksApp.post("/qstash", async (context) => {
5031
+ const currentSigningKey = context.env.QSTASH_CURRENT_SIGNING_KEY;
5032
+ const nextSigningKey = context.env.QSTASH_NEXT_SIGNING_KEY;
5033
+ if (!currentSigningKey || !nextSigningKey) {
5034
+ console.error("QStash webhook called but signing keys are not configured");
5035
+ return context.text("Configuration error", 500);
5036
+ }
5037
+ const receiver = new Receiver({
5038
+ currentSigningKey,
5039
+ nextSigningKey
5040
+ });
5041
+ const body = await context.req.text();
5042
+ const signature = context.req.header("Upstash-Signature");
5043
+ if (!signature) {
5044
+ return context.text("Missing signature", 401);
5045
+ }
5046
+ try {
5047
+ const isValid = await receiver.verify({
5048
+ signature,
5049
+ body
5050
+ });
5051
+ if (!isValid) {
5052
+ return context.text("Invalid signature", 401);
5053
+ }
5054
+ } catch (err) {
5055
+ console.error("QStash signature verification failed", err);
5056
+ return context.text("Invalid signature", 401);
5057
+ }
5058
+ try {
5059
+ const input = JSON.parse(body);
5060
+ const notificationRepository = context.get("notificationRepository");
5061
+ await notificationRepository.create({
5062
+ title: input.title,
5063
+ message: input.message,
5064
+ type: input.type ?? "info"
5065
+ });
5066
+ return context.text("OK", 200);
5067
+ } catch (error) {
5068
+ console.error("Failed to process QStash webhook payload", error);
5069
+ return context.text("Internal server error", 500);
5070
+ }
5071
+ });
5072
+
5073
+ // src/features/upload/index.ts
5074
+ import { Hono as Hono16 } from "hono";
5075
+ import { isMimeAccepted } from "@beechcms/core";
5076
+ var DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
5077
+ var ABSOLUTE_MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
5078
+ var PRESIGN_TTL_SECONDS = 900;
5079
+ function resolveMaxUploadBytes(env) {
5080
+ const raw = env.MAX_UPLOAD_BYTES;
5081
+ if (!raw) return DEFAULT_MAX_UPLOAD_BYTES;
5082
+ const parsed = Number.parseInt(raw, 10);
5083
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_UPLOAD_BYTES;
5084
+ return Math.min(parsed, ABSOLUTE_MAX_UPLOAD_BYTES);
5085
+ }
5086
+ function sanitizeFilename(name) {
5087
+ const base = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 100);
5088
+ return base || "file";
5089
+ }
5090
+ function generateObjectKey(originalName) {
5091
+ const timestamp = Math.floor(Date.now() / 1e3);
5092
+ return `${timestamp}-${sanitizeFilename(originalName)}`;
5093
+ }
5094
+ var FORCE_DOWNLOAD_MIME_PREFIXES = [
5095
+ "image/svg",
5096
+ "text/",
5097
+ "application/xml",
5098
+ "application/xhtml",
5099
+ "application/javascript",
5100
+ "application/x-javascript"
5101
+ ];
5102
+ function isActiveMimeType(mime) {
5103
+ const lower = mime.toLowerCase();
5104
+ return FORCE_DOWNLOAD_MIME_PREFIXES.some((prefix) => lower.startsWith(prefix));
5105
+ }
5106
+ var uploadRoutes = new Hono16();
5107
+ uploadRoutes.post("/upload/presign", async (c) => {
5108
+ let body;
5109
+ try {
5110
+ body = await c.req.json();
5111
+ } catch {
5112
+ return c.json({ error: "Invalid JSON body" }, 400);
5113
+ }
5114
+ const { filename, mimeType, sizeBytes } = body;
5115
+ if (typeof filename !== "string" || !filename.trim()) return c.json({ error: "filename is required" }, 400);
5116
+ if (typeof mimeType !== "string" || !mimeType.trim()) return c.json({ error: "mimeType is required" }, 400);
5117
+ if (typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || sizeBytes <= 0) {
5118
+ return c.json({ error: "sizeBytes must be a positive number" }, 400);
5119
+ }
5120
+ const maxBytes = resolveMaxUploadBytes(c.env);
5121
+ if (sizeBytes > maxBytes) return c.json({ error: `File too large. Max ${maxBytes} bytes` }, 400);
5122
+ if (!isMimeAccepted(mimeType, "any")) return c.json({ error: "File type not allowed" }, 400);
5123
+ const key = generateObjectKey(filename);
5124
+ const uploadUrl = await c.var.bucket.presignPut(key, {
5125
+ expiresIn: PRESIGN_TTL_SECONDS,
5126
+ contentType: mimeType,
5127
+ contentLength: sizeBytes
5128
+ });
5129
+ return c.json({ uploadUrl, key, expiresIn: PRESIGN_TTL_SECONDS }, 200);
5130
+ });
5131
+ uploadRoutes.post("/upload/confirm", async (c) => {
5132
+ const { bucket, mediaRepository: mediaRepo, systemStatsRepository: statsRepo } = c.var;
5133
+ let body;
5134
+ try {
5135
+ body = await c.req.json();
5136
+ } catch {
5137
+ return c.json({ error: "Invalid JSON body" }, 400);
5138
+ }
5139
+ const { key } = body;
5140
+ if (typeof key !== "string" || !key.trim()) return c.json({ error: "key is required" }, 400);
5141
+ const sanitizedKey = decodeURIComponent(key).replace(/\.\.[/\\]/g, "").replace(/[^a-zA-Z0-9._\-/]/g, "");
5142
+ if (!sanitizedKey || !/^\d+-[a-zA-Z0-9._-]+$/.test(sanitizedKey)) {
5143
+ return c.json({ error: "Invalid key format" }, 400);
5144
+ }
5145
+ const existing = await mediaRepo.getByKey(sanitizedKey);
5146
+ if (existing) return c.json({ url: bucket.getUrl(sanitizedKey) }, 200);
5147
+ const head = await bucket.head(sanitizedKey);
5148
+ if (!head) return c.json({ error: "Object not found in storage" }, 404);
5149
+ const size = head.size ?? 0;
5150
+ const maxBytes = resolveMaxUploadBytes(c.env);
5151
+ if (size > maxBytes) {
5152
+ return c.json({ error: `File too large. Max ${maxBytes} bytes` }, 400);
4947
5153
  }
4948
- const receiver = new Receiver({
4949
- currentSigningKey,
4950
- nextSigningKey
5154
+ const uploadedBy = c.var.jwtPayload?.sub ?? "";
5155
+ const mime = head.contentType ?? "application/octet-stream";
5156
+ const filename = sanitizedKey.replace(/^\d+-/, "") || sanitizedKey;
5157
+ await statsRepo.incrementStorage(size);
5158
+ await mediaRepo.trackUpload({
5159
+ key: sanitizedKey,
5160
+ filename,
5161
+ mime_type: mime,
5162
+ size_bytes: size,
5163
+ uploaded_by: uploadedBy
4951
5164
  });
4952
- const body = await context.req.text();
4953
- const signature = context.req.header("Upstash-Signature");
4954
- if (!signature) {
4955
- return context.text("Missing signature", 401);
5165
+ const jwtPayload = c.get("jwtPayload");
5166
+ if (jwtPayload) {
5167
+ c.get("activityLogger").log({
5168
+ action: "upload",
5169
+ entityType: "media",
5170
+ entityId: sanitizedKey,
5171
+ details: { name: filename, size, type: mime },
5172
+ actor: {
5173
+ id: jwtPayload.sub,
5174
+ email: jwtPayload.email ?? "unknown",
5175
+ name: [jwtPayload.name, jwtPayload.surname].filter(Boolean).join(" ") || null
5176
+ }
5177
+ });
5178
+ }
5179
+ return c.json({ url: bucket.getUrl(sanitizedKey) }, 200);
5180
+ });
5181
+ uploadRoutes.get("/upload/download-url/:key", async (c) => {
5182
+ let key = c.req.param("key");
5183
+ if (!key) return c.json({ error: "Missing key" }, 400);
5184
+ key = decodeURIComponent(key);
5185
+ key = key.replace(/\.\.[/\\]/g, "");
5186
+ key = key.replace(/[^a-zA-Z0-9._\-/]/g, "");
5187
+ if (!key) return c.json({ error: "Invalid key" }, 400);
5188
+ const media = await c.var.mediaRepository.getByKey(key).catch(() => null);
5189
+ if (!media) {
5190
+ return c.json({ error: "Media not found" }, 404);
5191
+ }
5192
+ const jwtPayload = c.var.jwtPayload;
5193
+ const userId = jwtPayload?.sub;
5194
+ const isAdmin = jwtPayload?.role === "admin";
5195
+ if (!isAdmin && userId !== media.uploaded_by) {
5196
+ return c.json({ error: "Forbidden" }, 403);
4956
5197
  }
5198
+ const head = await c.var.bucket.head(key);
5199
+ if (!head) return c.json({ error: "Object not found" }, 404);
5200
+ const downloadUrl = await c.var.bucket.presignGet(key, { expiresIn: PRESIGN_TTL_SECONDS });
5201
+ return c.json({ downloadUrl, expiresIn: PRESIGN_TTL_SECONDS }, 200);
5202
+ });
5203
+ uploadRoutes.delete("/upload/:key", async (c) => {
5204
+ let key = c.req.param("key");
5205
+ if (!key) return c.json({ error: "Missing key" }, 400);
5206
+ key = key.replace(/\.\.[/\\]/g, "");
5207
+ key = key.replace(/[^a-zA-Z0-9._\-/]/g, "");
5208
+ if (!key) return c.json({ error: "Invalid key" }, 400);
5209
+ const media = await c.var.mediaRepository.getByKey(key).catch(() => null);
5210
+ if (!media) {
5211
+ return c.json({ error: "Media not found" }, 404);
5212
+ }
5213
+ const jwtPayload = c.var.jwtPayload;
5214
+ const userId = jwtPayload?.sub;
5215
+ const isAdmin = jwtPayload?.role === "admin";
5216
+ if (!isAdmin && userId !== media.uploaded_by) {
5217
+ return c.json({ error: "Forbidden" }, 403);
5218
+ }
5219
+ await deleteR2Objects(c, [key]);
5220
+ return c.json({ success: true }, 200);
5221
+ });
5222
+ async function serveMediaHandler(c) {
5223
+ const key = c.req.param("key");
5224
+ if (!key) return new Response("Missing key", { status: 400 });
4957
5225
  try {
4958
- const isValid = await receiver.verify({
4959
- signature,
4960
- body
4961
- });
4962
- if (!isValid) {
4963
- return context.text("Invalid signature", 401);
5226
+ const object = await c.var.bucket.get(key);
5227
+ if (!object) return new Response("Not found", { status: 404 });
5228
+ const headers = new Headers();
5229
+ const originalMime = object.contentType ?? "application/octet-stream";
5230
+ if (isActiveMimeType(originalMime)) {
5231
+ headers.set("Content-Type", "application/octet-stream");
5232
+ headers.set("Content-Disposition", "attachment");
5233
+ } else {
5234
+ headers.set("Content-Type", originalMime);
4964
5235
  }
5236
+ headers.set("Content-Security-Policy", "default-src 'none'; sandbox");
5237
+ headers.set("X-Content-Type-Options", "nosniff");
5238
+ headers.set("Cache-Control", "public, max-age=31536000, immutable");
5239
+ return new Response(object.body, { status: 200, headers });
4965
5240
  } catch (err) {
4966
- console.error("QStash signature verification failed", err);
4967
- return context.text("Invalid signature", 401);
4968
- }
4969
- try {
4970
- const input = JSON.parse(body);
4971
- const notificationRepository = context.get("notificationRepository");
4972
- await notificationRepository.create({
4973
- title: input.title,
4974
- message: input.message,
4975
- type: input.type ?? "info"
4976
- });
4977
- return context.text("OK", 200);
4978
- } catch (error) {
4979
- console.error("Failed to process QStash webhook payload", error);
4980
- return context.text("Internal server error", 500);
5241
+ console.error(`[serveMediaHandler] Error serving file ${key}:`, err);
5242
+ return new Response("Internal error", { status: 500 });
4981
5243
  }
4982
- });
5244
+ }
4983
5245
 
4984
5246
  // src/public/public-errors.ts
4985
5247
  var PUBLIC_ERRORS = {
@@ -5036,15 +5298,28 @@ function publicRateLimitMiddleware() {
5036
5298
  return async (c, next) => {
5037
5299
  const readMethod = isReadMethod2(c.req.method);
5038
5300
  const limiterName = readMethod ? "publicApiRead" : "publicApiWrite";
5039
- const seed = c.req.param("seed") ?? "no-seed";
5301
+ const path = c.req.path;
5302
+ let seed = "no-seed";
5303
+ if (path.startsWith("/api/v1/public/")) {
5304
+ const remaining = path.slice("/api/v1/public/".length);
5305
+ const firstSegment = remaining.split("/")[0];
5306
+ if (firstSegment && firstSegment !== "health" && firstSegment !== "schema" && firstSegment !== "schema.html") {
5307
+ seed = firstSegment;
5308
+ }
5309
+ }
5040
5310
  const key = `${getClientIp(c.req)}:${seed}:${limiterName}`;
5041
5311
  const result = await c.get("rateLimiters").getLimiter(limiterName).checkLimit(key);
5042
5312
  if (!result.isAllowed) {
5313
+ const headers = {};
5314
+ if (result.retryAfterSeconds !== void 0) {
5315
+ headers["Retry-After"] = String(result.retryAfterSeconds);
5316
+ }
5043
5317
  return publicProblem(c, {
5044
5318
  type: "rate-limit-exceeded",
5045
5319
  title: "Too Many Requests",
5046
5320
  status: 429,
5047
- detail: "Too many requests"
5321
+ detail: "Too many requests",
5322
+ headers
5048
5323
  });
5049
5324
  }
5050
5325
  await next();
@@ -5101,7 +5376,7 @@ function withCachedResponse(edgeCache, cacheKey, response) {
5101
5376
  }
5102
5377
 
5103
5378
  // src/public/read-single.ts
5104
- import { EntryNotFoundError as EntryNotFoundError5 } from "@beechcms/core";
5379
+ import { EntryNotFoundError as EntryNotFoundError6 } from "@beechcms/core";
5105
5380
 
5106
5381
  // src/public/entry-projection.ts
5107
5382
  import { resolvePolicies as resolvePolicies7 } from "@beechcms/core";
@@ -5164,7 +5439,7 @@ async function readSingleEntry(input) {
5164
5439
  meta: buildPublicSingleMeta(seedSlug)
5165
5440
  };
5166
5441
  } catch (error) {
5167
- if (error instanceof EntryNotFoundError5) {
5442
+ if (error instanceof EntryNotFoundError6) {
5168
5443
  return { ok: false, detail: `Entry '${label}' not found for content type '${seedSlug}'.` };
5169
5444
  }
5170
5445
  throw error;
@@ -5291,6 +5566,7 @@ async function readListEntries(input) {
5291
5566
  const sortDir = (cleanStr(query.orderDir) ?? "desc").toLowerCase() === "asc" ? "ASC" : "DESC";
5292
5567
  const { items, total } = await repository.findMany(seed, {
5293
5568
  filters: engineFilters,
5569
+ filterLogic: parsedFilter?.logic,
5294
5570
  search: search || void 0,
5295
5571
  status: publishedOnly ? "published" : null,
5296
5572
  pagination: {
@@ -5363,10 +5639,10 @@ import { isValidContentStatus as isValidContentStatus3, SlugConflictError as Slu
5363
5639
  import { slugify as slugify3, generateEntrySlug } from "@beechcms/core";
5364
5640
 
5365
5641
  // src/public/sanitize.ts
5366
- import { validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload5 } from "@beechcms/core";
5642
+ import { validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload6 } from "@beechcms/core";
5367
5643
  function sanitizePublicPayload(seed, payload, options = {}) {
5368
5644
  const operation = options.operation ?? "create";
5369
- const result = validateAndSanitizeSeedPayload5(seed, payload, {
5645
+ const result = validateAndSanitizeSeedPayload6(seed, payload, {
5370
5646
  allowNull: options.allowNull ?? false,
5371
5647
  operation,
5372
5648
  requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
@@ -5498,7 +5774,7 @@ async function publicAddHandler(context) {
5498
5774
  }
5499
5775
 
5500
5776
  // src/public/public-edit.ts
5501
- import { isValidContentStatus as isValidContentStatus4, resolvePolicies as resolvePolicies8, EntryNotFoundError as EntryNotFoundError6 } from "@beechcms/core";
5777
+ import { isValidContentStatus as isValidContentStatus4, resolvePolicies as resolvePolicies8, EntryNotFoundError as EntryNotFoundError7 } from "@beechcms/core";
5502
5778
  var UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
5503
5779
  function errorMessage(context, error) {
5504
5780
  if (context.env.ENV !== "production" && error instanceof Error) return error.message;
@@ -5606,7 +5882,7 @@ async function publicEditHandler(context) {
5606
5882
  });
5607
5883
  return context.json({ success: true, id, slug: slugResult.value.nextSlug }, 200);
5608
5884
  } catch (error) {
5609
- if (error instanceof EntryNotFoundError6) {
5885
+ if (error instanceof EntryNotFoundError7) {
5610
5886
  return publicProblem(context, { type: "entry-not-found", title: "Not Found", status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` });
5611
5887
  }
5612
5888
  console.error("Public edit error:", error);
@@ -5678,10 +5954,10 @@ publicApp.post("/:seed/add", publicAddHandler);
5678
5954
  publicApp.put("/:seed/edit/:id", publicEditHandler);
5679
5955
  var publicRoutes = publicApp;
5680
5956
 
5681
- // src/search.ts
5957
+ // src/features/search/search.ts
5682
5958
  import { Hono as Hono18 } from "hono";
5683
5959
 
5684
- // src/search-utils.ts
5960
+ // src/features/search/search-utils.ts
5685
5961
  function encodeCursor(rank, entryId) {
5686
5962
  return btoa(`${rank}:${entryId}`);
5687
5963
  }
@@ -5786,7 +6062,7 @@ function mapSearchResultRow(row) {
5786
6062
  };
5787
6063
  }
5788
6064
 
5789
- // src/search.ts
6065
+ // src/features/search/search.ts
5790
6066
  var searchRouter = new Hono18();
5791
6067
  searchRouter.use("*", authMiddleware());
5792
6068
  searchRouter.get("/", async (c) => {
@@ -5827,19 +6103,19 @@ searchRouter.get("/", async (c) => {
5827
6103
  // src/middleware/repository.middleware.ts
5828
6104
  import { createMiddleware as createMiddleware2 } from "hono/factory";
5829
6105
 
5830
- // src/shared/content.repository.d1.ts
6106
+ // src/shared/db/repositories/content.repository.d1.ts
5831
6107
  import {
5832
- EntryNotFoundError as EntryNotFoundError7,
6108
+ EntryNotFoundError as EntryNotFoundError8,
5833
6109
  RepositoryError as RepositoryError2,
5834
- SlugConflictError as SlugConflictError3,
6110
+ SlugConflictError as SlugConflictError4,
5835
6111
  RelationTargetNotFoundError as RelationTargetNotFoundError2,
5836
6112
  buildSelectQuery,
5837
6113
  deserializeFromDb as deserializeFromDb2,
5838
6114
  serializeForDb
5839
6115
  } from "@beechcms/core";
5840
6116
 
5841
- // src/shared/base.repository.d1.ts
5842
- import { RepositoryError } from "@beechcms/core";
6117
+ // src/shared/db/repositories/base.repository.d1.ts
6118
+ import { RepositoryError, SlugConflictError as SlugConflictError3 } from "@beechcms/core";
5843
6119
  var BaseD1Repository = class {
5844
6120
  constructor(database) {
5845
6121
  this.database = database;
@@ -5857,11 +6133,14 @@ var BaseD1Repository = class {
5857
6133
  */
5858
6134
  mapError(error, context) {
5859
6135
  const message = error?.message || "Unknown database error";
6136
+ if (message.includes("UNIQUE constraint failed") && message.includes("slug")) {
6137
+ return new SlugConflictError3(`${context}: ${message}`);
6138
+ }
5860
6139
  return new RepositoryError(`${context}: ${message}`, error);
5861
6140
  }
5862
6141
  };
5863
6142
 
5864
- // src/shared/content.repository.d1.ts
6143
+ // src/shared/db/repositories/content.repository.d1.ts
5865
6144
  function multiRelBranches(seed) {
5866
6145
  return seed.branches.filter((b) => b.type === "relation" && b.multiple === true);
5867
6146
  }
@@ -5880,6 +6159,7 @@ var D1ContentRepository = class extends BaseD1Repository {
5880
6159
  this.hooks = hooks;
5881
6160
  }
5882
6161
  hooks;
6162
+ /** Assembles the {@link HookContext} passed to every lifecycle hook invocation. */
5883
6163
  hookCtx(seed, actor) {
5884
6164
  return { seed, repository: this, actor, db: this.database };
5885
6165
  }
@@ -5901,10 +6181,15 @@ var D1ContentRepository = class extends BaseD1Repository {
5901
6181
  data[branch.alias] = deserializeFromDb2(branch, row[branch.alias]);
5902
6182
  }
5903
6183
  }
6184
+ if (row.position !== void 0) data.position = row.position;
5904
6185
  return data;
5905
6186
  }
5906
6187
  /**
5907
6188
  * Fetches multi-relation arrays for a single entry and attaches them to `data`.
6189
+ *
6190
+ * @param seed The seed schema definition.
6191
+ * @param entryId Id of the entry to fetch relation targets for.
6192
+ * @param data Mutated in place: one array property per multi-relation branch alias.
5908
6193
  */
5909
6194
  async attachMultiRelations(seed, entryId, data) {
5910
6195
  const branches = multiRelBranches(seed);
@@ -5918,81 +6203,125 @@ var D1ContentRepository = class extends BaseD1Repository {
5918
6203
  }
5919
6204
  }
5920
6205
  /**
5921
- * Fetches multi-relation arrays for a list of entries and attaches them.
5922
- * One query per multi-relation branch (O(R) queries, R = branch count).
6206
+ * Builds one junction-table statement per multi-relation branch, selecting the rows whose
6207
+ * `parent_id` falls within the page defined by `idQuery` (the page selection re-projected to
6208
+ * ids only). Using a subquery instead of a bound `IN (?, ...)` id list keeps the statements
6209
+ * eligible for the same batch as the page query and avoids D1's bound-parameter limit.
6210
+ *
6211
+ * The subquery is wrapped in `SELECT id FROM (...)` because `kanbanOrder` projections carry an
6212
+ * extra `kp.position` column, and `IN` requires a single-column subselect.
5923
6213
  */
5924
- async attachMultiRelationsMany(seed, entries) {
5925
- const branches = multiRelBranches(seed);
5926
- if (branches.length === 0 || entries.length === 0) return;
5927
- const ids = entries.map((e) => e.id);
5928
- const placeholders = ids.map(() => "?").join(", ");
5929
- const stmts = branches.map(
6214
+ multiRelStatements(seed, branches, idQuery) {
6215
+ return branches.map(
5930
6216
  (b) => this.database.prepare(
5931
- `SELECT parent_id, target_id FROM ${jTable(seed.slug, b.alias)} WHERE parent_id IN (${placeholders}) ORDER BY parent_id, position ASC`
5932
- ).bind(...ids)
6217
+ `SELECT parent_id, target_id FROM ${jTable(seed.slug, b.alias)} WHERE parent_id IN (SELECT id FROM (${idQuery.sql})) ORDER BY position ASC`
6218
+ ).bind(...idQuery.bindings)
5933
6219
  );
5934
- const results = await this.database.batch(stmts);
6220
+ }
6221
+ /**
6222
+ * Groups junction rows by parent and attaches them to the entries: one array property per
6223
+ * multi-relation branch alias, per entry. `relResults[i]` must correspond to `branches[i]`.
6224
+ *
6225
+ * @param entries Mutated in place.
6226
+ */
6227
+ attachMultiRelationRows(branches, relResults, entries) {
5935
6228
  for (let i = 0; i < branches.length; i++) {
5936
6229
  const branch = branches[i];
5937
6230
  const byParent = /* @__PURE__ */ new Map();
5938
- for (const row of results[i].results ?? []) {
6231
+ for (const row of relResults[i].results ?? []) {
5939
6232
  const r = row;
5940
6233
  const pid = r.parent_id;
5941
- if (!byParent.has(pid)) byParent.set(pid, []);
5942
- byParent.get(pid).push(r.target_id);
6234
+ let targets = byParent.get(pid);
6235
+ if (!targets) {
6236
+ targets = [];
6237
+ byParent.set(pid, targets);
6238
+ }
6239
+ targets.push(r.target_id);
5943
6240
  }
5944
6241
  for (const entry of entries) {
5945
6242
  entry[branch.alias] = byParent.get(entry.id) ?? [];
5946
6243
  }
5947
6244
  }
5948
6245
  }
6246
+ /**
6247
+ * Finds multiple entries for a given seed table based on the provided query options.
6248
+ * Executes a single batch (one D1 round-trip) containing:
6249
+ * 1. A selection query to retrieve the items for the current page (with columns, limits, offsets, and ordering).
6250
+ * 2. A count query (using `isCount: true`) to retrieve the total number of matched items matching the same filters.
6251
+ * 3. One junction-table query per multi-relation branch, scoped to the page via an id subquery.
6252
+ *
6253
+ * @param seed The seed schema definition.
6254
+ * @param options Filtering, search, ordering, and pagination options.
6255
+ * @returns An object containing the list of serialized entries and the total count of matched items.
6256
+ * @throws RepositoryError if the database batch execution fails.
6257
+ */
5949
6258
  async findMany(seed, options) {
5950
6259
  try {
5951
6260
  const { sql, bindings } = buildSelectQuery(seed, options);
5952
- const countSql = sql.replace(/SELECT .* FROM/, "SELECT COUNT(*) as total FROM").replace(/ ORDER BY .*$/, "").replace(/ LIMIT \? OFFSET \?$/, "");
5953
- const countBindings = bindings.slice(0, bindings.length - (options.pagination ? 2 : 0));
5954
- const [batchResults, totalCountResult] = await this.database.batch([
6261
+ const { sql: countSql, bindings: countBindings } = buildSelectQuery(seed, {
6262
+ ...options,
6263
+ isCount: true
6264
+ });
6265
+ const branches = multiRelBranches(seed);
6266
+ const relStmts = branches.length > 0 ? this.multiRelStatements(seed, branches, buildSelectQuery(seed, { ...options, fields: ["id"] })) : [];
6267
+ const [batchResults, totalCountResult, ...relResults] = await this.database.batch([
5955
6268
  this.database.prepare(sql).bind(...bindings),
5956
- this.database.prepare(countSql).bind(...countBindings)
6269
+ this.database.prepare(countSql).bind(...countBindings),
6270
+ ...relStmts
5957
6271
  ]);
5958
6272
  const contentEntries = (batchResults.results || []).map((row) => this.rowToData(seed, row));
5959
6273
  const totalEntriesCount = totalCountResult.results?.[0]?.total || 0;
5960
- await this.attachMultiRelationsMany(seed, contentEntries);
6274
+ this.attachMultiRelationRows(branches, relResults, contentEntries);
5961
6275
  return { items: contentEntries, total: totalEntriesCount };
5962
6276
  } catch (error) {
5963
6277
  throw this.mapError(error, `findMany(${seed.slug})`);
5964
6278
  }
5965
6279
  }
6280
+ /**
6281
+ * Fetches a single entry by id, including its multi-relation arrays.
6282
+ * @throws EntryNotFoundError if no row with `id` exists in the seed's table.
6283
+ * @throws RepositoryError on any other database failure.
6284
+ */
5966
6285
  async findById(seed, id) {
5967
6286
  try {
5968
6287
  const tableName = this.getTableName(seed.slug);
5969
6288
  const entryRow = await this.database.prepare(`SELECT * FROM ${tableName} WHERE id = ? LIMIT 1`).bind(id).first();
5970
6289
  if (!entryRow) {
5971
- throw new EntryNotFoundError7(`Entry ${id} not found in ${seed.slug}`);
6290
+ throw new EntryNotFoundError8(`Entry ${id} not found in ${seed.slug}`);
5972
6291
  }
5973
6292
  const data = this.rowToData(seed, entryRow);
5974
6293
  await this.attachMultiRelations(seed, id, data);
5975
6294
  return data;
5976
6295
  } catch (error) {
5977
- if (error instanceof EntryNotFoundError7) throw error;
6296
+ if (error instanceof EntryNotFoundError8) throw error;
5978
6297
  throw this.mapError(error, `findById(${seed.slug}, ${id})`);
5979
6298
  }
5980
6299
  }
6300
+ /**
6301
+ * Fetches a single entry by its unique `slug`, including its multi-relation arrays.
6302
+ * @throws EntryNotFoundError if no row with `slug` exists in the seed's table.
6303
+ * @throws RepositoryError on any other database failure.
6304
+ */
5981
6305
  async findBySlug(seed, slug) {
5982
6306
  try {
5983
6307
  const tableName = this.getTableName(seed.slug);
5984
6308
  const entryRow = await this.database.prepare(`SELECT * FROM ${tableName} WHERE slug = ? LIMIT 1`).bind(slug).first();
5985
6309
  if (!entryRow) {
5986
- throw new EntryNotFoundError7(`Entry with slug "${slug}" not found in ${seed.slug}`);
6310
+ throw new EntryNotFoundError8(`Entry with slug "${slug}" not found in ${seed.slug}`);
5987
6311
  }
5988
6312
  const data = this.rowToData(seed, entryRow);
5989
6313
  await this.attachMultiRelations(seed, entryRow.id, data);
5990
6314
  return data;
5991
6315
  } catch (error) {
5992
- if (error instanceof EntryNotFoundError7) throw error;
6316
+ if (error instanceof EntryNotFoundError8) throw error;
5993
6317
  throw this.mapError(error, `findBySlug(${seed.slug}, ${slug})`);
5994
6318
  }
5995
6319
  }
6320
+ /**
6321
+ * Computes aggregate facets for a seed: entry counts per `status`, and the distinct set
6322
+ * of tag values present across all entries for each `tags`-typed branch (via `json_each`
6323
+ * over the JSON-serialized tag column).
6324
+ */
5996
6325
  async getFacets(seed) {
5997
6326
  try {
5998
6327
  const tableName = this.getTableName(seed.slug);
@@ -6012,6 +6341,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6012
6341
  throw this.mapError(error, `getFacets(${seed.slug})`);
6013
6342
  }
6014
6343
  }
6344
+ /**
6345
+ * Checks whether `slug` is already taken in the seed's table.
6346
+ * @param excludeId When provided (e.g. during an update), excludes that entry's own row from the check.
6347
+ */
6015
6348
  async existsSlug(seed, slug, excludeId) {
6016
6349
  try {
6017
6350
  const tableName = this.getTableName(seed.slug);
@@ -6027,6 +6360,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6027
6360
  throw this.mapError(error, `existsSlug(${seed.slug}, ${slug})`);
6028
6361
  }
6029
6362
  }
6363
+ /**
6364
+ * Builds the `INSERT` statement for an entry's main table row. Multi-relation branch
6365
+ * values are skipped (they're written as separate junction-table inserts by the caller).
6366
+ */
6030
6367
  buildCreateMainStmt(seed, id, slug, status, data) {
6031
6368
  const tableName = this.getTableName(seed.slug);
6032
6369
  const columnNames = ["id", "slug", "status"];
@@ -6044,6 +6381,15 @@ var D1ContentRepository = class extends BaseD1Repository {
6044
6381
  const mainSql = `INSERT INTO ${tableName} (${columnNames.join(", ")}) VALUES (${placeholders.join(", ")})`;
6045
6382
  return this.database.prepare(mainSql).bind(...queryBindings);
6046
6383
  }
6384
+ /**
6385
+ * Builds the `UPDATE` statement for an entry's main table row from whichever branch
6386
+ * aliases are present in `data` (partial update — absent keys are left untouched).
6387
+ * Multi-relation branches present in `data` are returned as `junctionUpdates` instead
6388
+ * of being included in the SQL, since they require separate delete+insert statements.
6389
+ *
6390
+ * @returns `stmt: null` when there is nothing to update on the main row (no scalar
6391
+ * fields and no `status` change) even if junction updates are still needed.
6392
+ */
6047
6393
  buildUpdateMainStmt(seed, id, data, status) {
6048
6394
  const tableName = this.getTableName(seed.slug);
6049
6395
  const updateClauses = [];
@@ -6054,6 +6400,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6054
6400
  updateClauses.push("status = ?");
6055
6401
  queryBindings.push(status);
6056
6402
  }
6403
+ if (data.slug) {
6404
+ updateClauses.push("slug = ?");
6405
+ queryBindings.push(data.slug);
6406
+ }
6057
6407
  for (const branch of seed.branches) {
6058
6408
  if (mRelAliases.has(branch.alias)) continue;
6059
6409
  if (Object.hasOwn(data, branch.alias)) {
@@ -6073,18 +6423,28 @@ var D1ContentRepository = class extends BaseD1Repository {
6073
6423
  junctionUpdates
6074
6424
  };
6075
6425
  }
6426
+ /** Builds one `INSERT` per target id into a live multi-relation junction table, preserving array order via `position`. */
6076
6427
  buildJunctionInserts(seedSlug, parentId, branchAlias, targetIds) {
6077
6428
  const jt = jTable(seedSlug, branchAlias);
6078
6429
  return targetIds.map(
6079
6430
  (targetId, i) => this.database.prepare(`INSERT INTO ${jt} (parent_id, target_id, position) VALUES (?, ?, ?)`).bind(parentId, targetId, i)
6080
6431
  );
6081
6432
  }
6433
+ /** Builds one `INSERT` per target id into a draft multi-relation junction table, preserving array order via `position`. */
6082
6434
  buildDraftJunctionInserts(seedSlug, entryId, branchAlias, targetIds) {
6083
6435
  const jdt = jDraftTable(seedSlug, branchAlias);
6084
6436
  return targetIds.map(
6085
6437
  (targetId, i) => this.database.prepare(`INSERT INTO ${jdt} (entry_id, target_id, position) VALUES (?, ?, ?)`).bind(entryId, targetId, i)
6086
6438
  );
6087
6439
  }
6440
+ /**
6441
+ * Validates that every relation target referenced by a draft still exists before publish,
6442
+ * for both single-relation columns and multi-relation draft junction rows.
6443
+ *
6444
+ * @throws RelationTargetNotFoundError if any referenced target id is missing from its target seed's table.
6445
+ * @returns The multi-relation branches and their resolved (validated) draft target ids,
6446
+ * so the caller can reuse them without re-querying the draft junction tables.
6447
+ */
6088
6448
  async validatePublishDraftRelations(seed, draftRow, entryId) {
6089
6449
  for (const branch of singleRelBranches(seed)) {
6090
6450
  const value = draftRow[branch.alias];
@@ -6118,6 +6478,12 @@ var D1ContentRepository = class extends BaseD1Repository {
6118
6478
  }
6119
6479
  return { mRelBranches, mRelDraftIds };
6120
6480
  }
6481
+ /**
6482
+ * Builds the junction-table statements for one array-typed {@link BulkFieldUpdate}:
6483
+ * `array_replace` (delete-all + reinsert), `array_add` (append via `INSERT OR IGNORE`
6484
+ * with position computed from current max), or `array_remove` (targeted delete).
6485
+ * Returns an empty array for a no-op (e.g. `array_remove` with an empty value list).
6486
+ */
6121
6487
  getBulkArrayUpdateStmts(seedSlug, id, alias, update) {
6122
6488
  const jt = jTable(seedSlug, alias);
6123
6489
  const stmts = [];
@@ -6143,16 +6509,23 @@ var D1ContentRepository = class extends BaseD1Repository {
6143
6509
  }
6144
6510
  return stmts;
6145
6511
  }
6146
- async processBulkUpdateSingle(seedSlug, id, fields, tableName) {
6512
+ /**
6513
+ * Applies a bulk field update to a single entry as one batch: a main-table `UPDATE` for
6514
+ * `set`-kind fields plus any junction statements for array-kind fields.
6515
+ * @throws Error('not-found') if the main `UPDATE` affects zero rows (id doesn't exist).
6516
+ */
6517
+ async processBulkUpdateSingle(seed, id, fields, tableName) {
6147
6518
  const stmts = [];
6148
6519
  const setClauses = ["updated_at = (unixepoch())"];
6149
6520
  const setBindings = [];
6150
6521
  for (const [alias, update] of Object.entries(fields)) {
6151
6522
  if (update.kind === "set") {
6152
- setClauses.unshift(`${alias} = ?`);
6153
- setBindings.push(update.value);
6523
+ const branch = seed.branches.find((b) => b.alias === alias);
6524
+ const serializedValue = branch ? serializeForDb(branch, update.value) : update.value;
6525
+ setClauses.push(`${alias} = ?`);
6526
+ setBindings.push(serializedValue);
6154
6527
  } else {
6155
- stmts.push(...this.getBulkArrayUpdateStmts(seedSlug, id, alias, update));
6528
+ stmts.push(...this.getBulkArrayUpdateStmts(seed.slug, id, alias, update));
6156
6529
  }
6157
6530
  }
6158
6531
  stmts.unshift(
@@ -6163,12 +6536,17 @@ var D1ContentRepository = class extends BaseD1Repository {
6163
6536
  throw new Error("not-found");
6164
6537
  }
6165
6538
  }
6166
- async processBulkUpdateChunk(seedSlug, chunk, fields, tableName) {
6539
+ /**
6540
+ * Sequentially applies a bulk field update to each id in `chunk`, isolating per-id
6541
+ * failures (not-found, FK violation, or other error) into the `failed` list rather than
6542
+ * aborting the whole chunk.
6543
+ */
6544
+ async processBulkUpdateChunk(seed, chunk, fields, tableName) {
6167
6545
  let updated = 0;
6168
6546
  const failed = [];
6169
6547
  for (const id of chunk) {
6170
6548
  try {
6171
- await this.processBulkUpdateSingle(seedSlug, id, fields, tableName);
6549
+ await this.processBulkUpdateSingle(seed, id, fields, tableName);
6172
6550
  updated++;
6173
6551
  } catch (err) {
6174
6552
  const msg = err instanceof Error ? err.message : String(err);
@@ -6182,6 +6560,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6182
6560
  }
6183
6561
  return { updated, failed };
6184
6562
  }
6563
+ /**
6564
+ * Creates a new entry: runs `beforeCreate` (which may transform the payload), inserts the
6565
+ * main row plus any multi-relation junction rows as a single batch, then runs `afterCreate`.
6566
+ *
6567
+ * @throws SlugConflictError if `slug` already exists for this seed.
6568
+ * @throws RepositoryError on any other database failure.
6569
+ * @remarks `afterCreate` runs after the batch has committed, so it cannot roll back the write.
6570
+ */
6185
6571
  async create(seed, id, slug, status, data, options) {
6186
6572
  let payload = data;
6187
6573
  if (this.hooks?.beforeCreate) {
@@ -6190,7 +6576,7 @@ var D1ContentRepository = class extends BaseD1Repository {
6190
6576
  }
6191
6577
  try {
6192
6578
  if (await this.existsSlug(seed, slug)) {
6193
- throw new SlugConflictError3(`Slug "${slug}" already exists for ${seed.slug}`);
6579
+ throw new SlugConflictError4(`Slug "${slug}" already exists for ${seed.slug}`);
6194
6580
  }
6195
6581
  const batchStmts = [
6196
6582
  this.buildCreateMainStmt(seed, id, slug, status, payload)
@@ -6206,7 +6592,7 @@ var D1ContentRepository = class extends BaseD1Repository {
6206
6592
  await this.database.batch(batchStmts);
6207
6593
  }
6208
6594
  } catch (error) {
6209
- if (error instanceof SlugConflictError3) throw error;
6595
+ if (error instanceof SlugConflictError4) throw error;
6210
6596
  throw this.mapError(error, `create(${seed.slug})`);
6211
6597
  }
6212
6598
  if (this.hooks?.afterCreate) {
@@ -6214,6 +6600,15 @@ var D1ContentRepository = class extends BaseD1Repository {
6214
6600
  await this.hooks.afterCreate(entry, this.hookCtx(seed, options?.actor));
6215
6601
  }
6216
6602
  }
6603
+ /**
6604
+ * Partially updates an existing entry: runs `beforeUpdate`, applies main-row and
6605
+ * junction-table changes as a batch, then runs `afterUpdate`. A no-op payload (no scalar
6606
+ * fields, no multi-relation fields, no status change) returns without touching the database.
6607
+ *
6608
+ * @throws EntryNotFoundError if the main-row `UPDATE` affects zero rows.
6609
+ * @throws RepositoryError on any other database failure.
6610
+ * @remarks `afterUpdate` runs after the batch has committed, so it cannot roll back the write.
6611
+ */
6217
6612
  async update(seed, id, data, status, options) {
6218
6613
  let payload = data;
6219
6614
  if (this.hooks?.beforeUpdate) {
@@ -6234,16 +6629,16 @@ var D1ContentRepository = class extends BaseD1Repository {
6234
6629
  if (batchStmts.length === 1) {
6235
6630
  const updateResult = await batchStmts[0].run();
6236
6631
  if (updateResult.meta.changes === 0) {
6237
- throw new EntryNotFoundError7(`Entry ${id} not found in ${seed.slug}`);
6632
+ throw new EntryNotFoundError8(`Entry ${id} not found in ${seed.slug}`);
6238
6633
  }
6239
6634
  } else if (batchStmts.length > 1) {
6240
6635
  const results = await this.database.batch(batchStmts);
6241
6636
  if (stmt && (results[0].meta?.changes ?? 0) === 0) {
6242
- throw new EntryNotFoundError7(`Entry ${id} not found in ${seed.slug}`);
6637
+ throw new EntryNotFoundError8(`Entry ${id} not found in ${seed.slug}`);
6243
6638
  }
6244
6639
  }
6245
6640
  } catch (error) {
6246
- if (error instanceof EntryNotFoundError7) throw error;
6641
+ if (error instanceof EntryNotFoundError8) throw error;
6247
6642
  throw this.mapError(error, `update(${seed.slug}, ${id})`);
6248
6643
  }
6249
6644
  if (this.hooks?.afterUpdate) {
@@ -6251,6 +6646,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6251
6646
  await this.hooks.afterUpdate(entry, this.hookCtx(seed, options?.actor));
6252
6647
  }
6253
6648
  }
6649
+ /**
6650
+ * Deletes an entry: runs `beforeDelete`, reads the row (for the return value), deletes it,
6651
+ * then runs `afterDelete`. Relies on database-level `ON DELETE CASCADE` for junction rows.
6652
+ *
6653
+ * @returns The deserialized row as it existed immediately before deletion.
6654
+ * @throws EntryNotFoundError if no row with `id` exists.
6655
+ * @remarks `afterDelete` runs after the delete has committed, so it cannot roll back the write.
6656
+ */
6254
6657
  async delete(seed, id, options) {
6255
6658
  if (this.hooks?.beforeDelete) {
6256
6659
  await this.hooks.beforeDelete(id, this.hookCtx(seed, options?.actor));
@@ -6260,12 +6663,12 @@ var D1ContentRepository = class extends BaseD1Repository {
6260
6663
  const tableName = this.getTableName(seed.slug);
6261
6664
  const entryRow = await this.database.prepare(`SELECT * FROM ${tableName} WHERE id = ?`).bind(id).first();
6262
6665
  if (!entryRow) {
6263
- throw new EntryNotFoundError7(`Entry ${id} not found in ${seed.slug}`);
6666
+ throw new EntryNotFoundError8(`Entry ${id} not found in ${seed.slug}`);
6264
6667
  }
6265
6668
  await this.database.prepare(`DELETE FROM ${tableName} WHERE id = ?`).bind(id).run();
6266
6669
  row = this.rowToData(seed, entryRow);
6267
6670
  } catch (error) {
6268
- if (error instanceof EntryNotFoundError7) throw error;
6671
+ if (error instanceof EntryNotFoundError8) throw error;
6269
6672
  throw this.mapError(error, `delete(${seed.slug}, ${id})`);
6270
6673
  }
6271
6674
  if (this.hooks?.afterDelete) {
@@ -6273,6 +6676,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6273
6676
  }
6274
6677
  return { row };
6275
6678
  }
6679
+ /**
6680
+ * Atomically increments/decrements a numeric field in a single conditional `UPDATE`
6681
+ * (guarded by optional `min`/`max` bounds evaluated against the new value), avoiding
6682
+ * read-modify-write races.
6683
+ *
6684
+ * @throws RepositoryError if `fieldName` isn't a `number`-typed branch on `seed`, or if
6685
+ * the conditional `UPDATE` affects zero rows (entry not found, or bound exceeded).
6686
+ */
6276
6687
  async mutateField(seed, id, fieldName, operation, options) {
6277
6688
  try {
6278
6689
  const branch = seed.branches.find((b) => b.alias === fieldName);
@@ -6302,6 +6713,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6302
6713
  throw this.mapError(error, `mutateField(${seed.slug}, ${id}, ${fieldName})`);
6303
6714
  }
6304
6715
  }
6716
+ /**
6717
+ * Executes a heterogeneous list of {@link BatchWrite} operations (create/update/mutateField)
6718
+ * as a single atomic `database.batch` call. Does not invoke lifecycle hooks — callers needing
6719
+ * hook side effects should use {@link create}/{@link update}/{@link mutateField} instead.
6720
+ *
6721
+ * @throws RepositoryError if any operation references a non-numeric field for `mutateField`,
6722
+ * or on any other database failure.
6723
+ */
6305
6724
  async runBatch(operations) {
6306
6725
  if (operations.length === 0) return;
6307
6726
  try {
@@ -6350,6 +6769,11 @@ var D1ContentRepository = class extends BaseD1Repository {
6350
6769
  throw this.mapError(error, "runBatch");
6351
6770
  }
6352
6771
  }
6772
+ /**
6773
+ * Upserts a draft row for `entryId` (`INSERT ... ON CONFLICT DO UPDATE` keyed on `entry_id`)
6774
+ * plus a full delete+reinsert of each multi-relation branch's draft junction rows.
6775
+ * @throws RepositoryError if `seed.allowDrafts` is false, or on any database failure.
6776
+ */
6353
6777
  async saveDraft(seed, entryId, data) {
6354
6778
  try {
6355
6779
  if (!seed.allowDrafts) {
@@ -6358,6 +6782,21 @@ var D1ContentRepository = class extends BaseD1Repository {
6358
6782
  const draftTableName = this.getTableName(seed.slug, true);
6359
6783
  const mRelBranches = multiRelBranches(seed);
6360
6784
  const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
6785
+ let existingTouched = [];
6786
+ try {
6787
+ const existing = await this.database.prepare(`SELECT _touched_fields FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId).first();
6788
+ if (existing && existing._touched_fields) {
6789
+ existingTouched = JSON.parse(existing._touched_fields);
6790
+ }
6791
+ } catch {
6792
+ }
6793
+ const currentTouched = new Set(existingTouched);
6794
+ for (const branch of seed.branches) {
6795
+ if (Object.hasOwn(data, branch.alias)) {
6796
+ currentTouched.add(branch.alias);
6797
+ }
6798
+ }
6799
+ const updatedTouchedFields = Array.from(currentTouched);
6361
6800
  const columnNames = ["entry_id"];
6362
6801
  const placeholders = ["?"];
6363
6802
  const queryBindings = [entryId];
@@ -6372,6 +6811,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6372
6811
  updateClauses.push(`${branch.alias} = EXCLUDED.${branch.alias}`);
6373
6812
  }
6374
6813
  }
6814
+ columnNames.push("_touched_fields");
6815
+ placeholders.push("?");
6816
+ queryBindings.push(JSON.stringify(updatedTouchedFields));
6817
+ updateClauses.push(`_touched_fields = EXCLUDED._touched_fields`);
6375
6818
  updateClauses.push("updated_at = (unixepoch())");
6376
6819
  const sql = `
6377
6820
  INSERT INTO ${draftTableName} (${columnNames.join(", ")})
@@ -6403,6 +6846,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6403
6846
  throw this.mapError(error, `saveDraft(${seed.slug}, ${entryId})`);
6404
6847
  }
6405
6848
  }
6849
+ /**
6850
+ * Fetches the draft row for `entryId`, if any, merged with its multi-relation draft arrays.
6851
+ * @returns `null` if `seed.allowDrafts` is false or no draft row exists.
6852
+ */
6406
6853
  async getDraft(seed, entryId) {
6407
6854
  try {
6408
6855
  if (!seed.allowDrafts) return null;
@@ -6411,10 +6858,20 @@ var D1ContentRepository = class extends BaseD1Repository {
6411
6858
  if (!draftRow) return null;
6412
6859
  const mRelBranches = multiRelBranches(seed);
6413
6860
  const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
6861
+ let touchedFields = [];
6862
+ if (draftRow && typeof draftRow["_touched_fields"] === "string") {
6863
+ try {
6864
+ touchedFields = JSON.parse(draftRow["_touched_fields"]);
6865
+ } catch {
6866
+ }
6867
+ }
6868
+ const touchedSet = new Set(touchedFields);
6414
6869
  const draftData = {};
6415
6870
  for (const branch of seed.branches) {
6416
6871
  if (mRelAliases.has(branch.alias)) continue;
6417
- if (draftRow[branch.alias] !== null) {
6872
+ if (touchedSet.has(branch.alias)) {
6873
+ draftData[branch.alias] = draftRow[branch.alias] !== null ? deserializeFromDb2(branch, draftRow[branch.alias]) : null;
6874
+ } else if (draftRow[branch.alias] !== null) {
6418
6875
  draftData[branch.alias] = deserializeFromDb2(branch, draftRow[branch.alias]);
6419
6876
  }
6420
6877
  }
@@ -6424,9 +6881,12 @@ var D1ContentRepository = class extends BaseD1Repository {
6424
6881
  );
6425
6882
  const results = await this.database.batch(stmts);
6426
6883
  for (let i = 0; i < mRelBranches.length; i++) {
6884
+ const alias = mRelBranches[i].alias;
6427
6885
  const ids = (results[i].results ?? []).map((r) => r.target_id);
6428
- if (ids.length > 0) {
6429
- draftData[mRelBranches[i].alias] = ids;
6886
+ if (touchedSet.has(alias)) {
6887
+ draftData[alias] = ids;
6888
+ } else if (ids.length > 0) {
6889
+ draftData[alias] = ids;
6430
6890
  }
6431
6891
  }
6432
6892
  }
@@ -6435,6 +6895,7 @@ var D1ContentRepository = class extends BaseD1Repository {
6435
6895
  throw this.mapError(error, `getDraft(${seed.slug}, ${entryId})`);
6436
6896
  }
6437
6897
  }
6898
+ /** Checks whether a draft row exists for `entryId`. Always `false` if `seed.allowDrafts` is false. */
6438
6899
  async hasDraft(seed, entryId) {
6439
6900
  try {
6440
6901
  if (!seed.allowDrafts) return false;
@@ -6445,6 +6906,15 @@ var D1ContentRepository = class extends BaseD1Repository {
6445
6906
  throw this.mapError(error, `hasDraft(${seed.slug}, ${entryId})`);
6446
6907
  }
6447
6908
  }
6909
+ /**
6910
+ * Promotes a draft to the live entry: validates all relation targets referenced by the
6911
+ * draft still exist, copies the draft's scalar fields into the live row, replaces the
6912
+ * live junction rows with the draft's, and deletes the draft — all as one batch.
6913
+ * A no-op if `seed.allowDrafts` is false.
6914
+ *
6915
+ * @throws EntryNotFoundError if no draft row exists for `entryId`.
6916
+ * @throws RelationTargetNotFoundError if any relation the draft references no longer exists.
6917
+ */
6448
6918
  async publishDraft(seed, entryId) {
6449
6919
  try {
6450
6920
  if (!seed.allowDrafts) return;
@@ -6452,56 +6922,85 @@ var D1ContentRepository = class extends BaseD1Repository {
6452
6922
  const liveTableName = this.getTableName(seed.slug);
6453
6923
  const draftRow = await this.database.prepare(`SELECT * FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId).first();
6454
6924
  if (!draftRow) {
6455
- throw new EntryNotFoundError7(`No draft found for ${entryId} in ${seed.slug}`);
6925
+ throw new EntryNotFoundError8(`No draft found for ${entryId} in ${seed.slug}`);
6456
6926
  }
6927
+ let touchedFields = [];
6928
+ if (draftRow && typeof draftRow["_touched_fields"] === "string") {
6929
+ try {
6930
+ touchedFields = JSON.parse(draftRow["_touched_fields"]);
6931
+ } catch {
6932
+ }
6933
+ }
6934
+ const touchedSet = new Set(touchedFields);
6457
6935
  const { mRelBranches, mRelDraftIds } = await this.validatePublishDraftRelations(seed, draftRow, entryId);
6458
6936
  const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
6459
6937
  const updateClauses = [];
6460
6938
  const queryBindings = [];
6461
6939
  for (const branch of seed.branches) {
6462
6940
  if (mRelAliases.has(branch.alias)) continue;
6463
- if (draftRow[branch.alias] !== null) {
6941
+ if (touchedSet.has(branch.alias)) {
6942
+ updateClauses.push(`${branch.alias} = ?`);
6943
+ queryBindings.push(draftRow[branch.alias]);
6944
+ } else if (draftRow[branch.alias] !== null) {
6464
6945
  updateClauses.push(`${branch.alias} = ?`);
6465
6946
  queryBindings.push(draftRow[branch.alias]);
6466
6947
  }
6467
6948
  }
6468
- updateClauses.push("updated_at = (unixepoch())");
6469
- const updateSql = `UPDATE ${liveTableName} SET ${updateClauses.join(", ")} WHERE id = ?`;
6470
- queryBindings.push(entryId);
6949
+ let updateSql = "";
6950
+ if (updateClauses.length > 0) {
6951
+ updateClauses.push("updated_at = (unixepoch())");
6952
+ updateSql = `UPDATE ${liveTableName} SET ${updateClauses.join(", ")} WHERE id = ?`;
6953
+ queryBindings.push(entryId);
6954
+ } else {
6955
+ updateSql = `UPDATE ${liveTableName} SET updated_at = (unixepoch()) WHERE id = ?`;
6956
+ queryBindings.push(entryId);
6957
+ }
6471
6958
  const batchStmts = [
6472
6959
  this.database.prepare(updateSql).bind(...queryBindings),
6473
6960
  this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId)
6474
6961
  ];
6475
6962
  for (const branch of mRelBranches) {
6476
- const lt = jTable(seed.slug, branch.alias);
6477
6963
  const jdt = jDraftTable(seed.slug, branch.alias);
6478
- const targetIds = mRelDraftIds.get(branch.alias) ?? [];
6479
- batchStmts.push(
6480
- this.database.prepare(`DELETE FROM ${lt} WHERE parent_id = ?`).bind(entryId),
6481
- this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId),
6482
- ...this.buildJunctionInserts(seed.slug, entryId, branch.alias, targetIds)
6483
- );
6964
+ if (touchedSet.has(branch.alias)) {
6965
+ const lt = jTable(seed.slug, branch.alias);
6966
+ const targetIds = mRelDraftIds.get(branch.alias) ?? [];
6967
+ batchStmts.push(
6968
+ this.database.prepare(`DELETE FROM ${lt} WHERE parent_id = ?`).bind(entryId),
6969
+ this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId),
6970
+ ...this.buildJunctionInserts(seed.slug, entryId, branch.alias, targetIds)
6971
+ );
6972
+ } else {
6973
+ batchStmts.push(
6974
+ this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId)
6975
+ );
6976
+ }
6484
6977
  }
6485
6978
  await this.database.batch(batchStmts);
6486
6979
  } catch (error) {
6487
- if (error instanceof EntryNotFoundError7) throw error;
6980
+ if (error instanceof EntryNotFoundError8) throw error;
6488
6981
  if (error instanceof RelationTargetNotFoundError2) throw error;
6489
6982
  throw this.mapError(error, `publishDraft(${seed.slug}, ${entryId})`);
6490
6983
  }
6491
6984
  }
6492
- async bulkUpdate(seedSlug, ids, fields) {
6985
+ /**
6986
+ * Applies the same set of field updates across many entries, chunked at 50 ids per batch
6987
+ * to bound per-call payload size. Per-id failures are collected rather than aborting the
6988
+ * whole operation — see {@link processBulkUpdateSingle} for failure classification.
6989
+ */
6990
+ async bulkUpdate(seed, ids, fields) {
6493
6991
  const CHUNK = 50;
6494
- const tableName = this.getTableName(seedSlug);
6992
+ const tableName = this.getTableName(seed.slug);
6495
6993
  let updated = 0;
6496
6994
  const failed = [];
6497
6995
  for (let offset = 0; offset < ids.length; offset += CHUNK) {
6498
6996
  const chunk = ids.slice(offset, offset + CHUNK);
6499
- const res = await this.processBulkUpdateChunk(seedSlug, chunk, fields, tableName);
6997
+ const res = await this.processBulkUpdateChunk(seed, chunk, fields, tableName);
6500
6998
  updated += res.updated;
6501
6999
  failed.push(...res.failed);
6502
7000
  }
6503
7001
  return { updated, failed };
6504
7002
  }
7003
+ /** Deletes the draft row for `entryId`, if any. A no-op if `seed.allowDrafts` is false. */
6505
7004
  async deleteDraft(seed, entryId) {
6506
7005
  try {
6507
7006
  if (!seed.allowDrafts) return;
@@ -6511,6 +7010,15 @@ var D1ContentRepository = class extends BaseD1Repository {
6511
7010
  throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`);
6512
7011
  }
6513
7012
  }
7013
+ /**
7014
+ * Finds every pending draft across the given seeds, `UNION ALL`-ing one query per seed
7015
+ * so results span heterogeneous `content_{slug}_drafts` tables, then left-joins each
7016
+ * against `activity_logs` to attribute the most recent "draft saved" action.
7017
+ *
7018
+ * @param seeds Seeds to search; drafts are looked up per-seed since draft tables aren't unified.
7019
+ * @returns Draft summaries ordered by most-recently-updated first. `title` falls back to the
7020
+ * draft's own `id` if the display-name column is blank on both the draft and its live counterpart.
7021
+ */
6514
7022
  async findPendingDrafts(seeds) {
6515
7023
  try {
6516
7024
  if (seeds.length === 0) return [];
@@ -6520,6 +7028,8 @@ var D1ContentRepository = class extends BaseD1Repository {
6520
7028
  const draftTable = `content_${seed.slug}_drafts`;
6521
7029
  const liveTable = `content_${seed.slug}`;
6522
7030
  const titleCol = seed.displayNameAlias;
7031
+ const isScalar = seed.branches.some((b) => b.alias === titleCol && !(b.type === "relation" && b.multiple === true));
7032
+ const titleExpr = isScalar ? `COALESCE(d.${titleCol}, l.${titleCol})` : `COALESCE(l.slug, d.entry_id)`;
6523
7033
  const seedLabel = seed.labelPlural ?? seed.label;
6524
7034
  unionSelects.push(`
6525
7035
  SELECT
@@ -6527,7 +7037,7 @@ var D1ContentRepository = class extends BaseD1Repository {
6527
7037
  ? AS seed_label,
6528
7038
  d.entry_id AS id,
6529
7039
  d.updated_at AS updated_at,
6530
- COALESCE(d.${titleCol}, l.${titleCol}) AS title
7040
+ ${titleExpr} AS title
6531
7041
  FROM ${draftTable} d
6532
7042
  LEFT JOIN ${liveTable} l ON l.id = d.entry_id
6533
7043
  `);
@@ -6574,9 +7084,48 @@ var D1ContentRepository = class extends BaseD1Repository {
6574
7084
  throw this.mapError(error, "findPendingDrafts");
6575
7085
  }
6576
7086
  }
7087
+ /**
7088
+ * Updates an entry's field values (optional) and its kanban board position atomically in one
7089
+ * batch: an optional main-row/junction update via {@link buildUpdateMainStmt}, plus an upsert
7090
+ * into `kanban_positions` keyed on `(seed_slug, entry_id, axis_branch_id)`.
7091
+ *
7092
+ * @param patch Field changes to apply alongside the drag/drop, or `null` if only position changed.
7093
+ * @param position Opaque fractional-index (or similar) position value used for ordering within the axis bucket.
7094
+ * @param axisBranchId Identifies which branch/column axis this position is relative to (e.g. status column).
7095
+ * @remarks Unlike {@link create}/{@link update}, this method does not invoke `beforeUpdate`/`afterUpdate`
7096
+ * lifecycle hooks — the `_ctx.actor` parameter is currently unused. Kanban drag-and-drop writes
7097
+ * therefore bypass hook-based side effects (e.g. audit logging) that `update()` would trigger.
7098
+ */
7099
+ async updateWithKanbanPosition(seed, id, patch, position, axisBranchId, _ctx) {
7100
+ try {
7101
+ const stmts = [];
7102
+ if (patch) {
7103
+ const { stmt, junctionUpdates } = this.buildUpdateMainStmt(seed, id, patch);
7104
+ if (stmt) stmts.push(stmt);
7105
+ for (const branch of junctionUpdates) {
7106
+ const jt = jTable(seed.slug, branch.alias);
7107
+ const value = patch[branch.alias] ?? [];
7108
+ stmts.push(this.database.prepare(`DELETE FROM ${jt} WHERE parent_id = ?`).bind(id));
7109
+ stmts.push(...this.buildJunctionInserts(seed.slug, id, branch.alias, value));
7110
+ }
7111
+ }
7112
+ stmts.push(
7113
+ this.database.prepare(`
7114
+ INSERT INTO kanban_positions (seed_slug, entry_id, axis_branch_id, position, updated_at)
7115
+ VALUES (?, ?, ?, ?, unixepoch())
7116
+ ON CONFLICT(seed_slug, entry_id, axis_branch_id)
7117
+ DO UPDATE SET position = excluded.position, updated_at = excluded.updated_at
7118
+ `).bind(seed.slug, id, axisBranchId, position)
7119
+ );
7120
+ await this.database.batch(stmts);
7121
+ return { success: true };
7122
+ } catch (error) {
7123
+ throw this.mapError(error, `updateWithKanbanPosition(${seed.slug}, ${id})`);
7124
+ }
7125
+ }
6577
7126
  };
6578
7127
 
6579
- // src/shared/idempotency.repository.d1.ts
7128
+ // src/shared/db/repositories/idempotency.repository.d1.ts
6580
7129
  var D1IdempotencyRepository = class extends BaseD1Repository {
6581
7130
  /**
6582
7131
  * Looks up an idempotency record by its key.
@@ -6627,7 +7176,7 @@ var D1IdempotencyRepository = class extends BaseD1Repository {
6627
7176
  }
6628
7177
  };
6629
7178
 
6630
- // src/shared/media.repository.d1.ts
7179
+ // src/shared/db/repositories/media.repository.d1.ts
6631
7180
  var D1MediaRepository = class {
6632
7181
  constructor(database) {
6633
7182
  this.database = database;
@@ -6683,7 +7232,7 @@ var D1MediaRepository = class {
6683
7232
  }
6684
7233
  };
6685
7234
 
6686
- // src/shared/system-stats.repository.d1.ts
7235
+ // src/shared/db/repositories/system-stats.repository.d1.ts
6687
7236
  var D1SystemStatsRepository = class {
6688
7237
  constructor(database) {
6689
7238
  this.database = database;
@@ -6722,7 +7271,7 @@ var D1SystemStatsRepository = class {
6722
7271
  }
6723
7272
  };
6724
7273
 
6725
- // src/shared/d1-user.repository.ts
7274
+ // src/shared/db/repositories/d1-user.repository.ts
6726
7275
  function rowToRecord(row) {
6727
7276
  return {
6728
7277
  id: row.id,
@@ -6789,7 +7338,7 @@ var D1UserRepository = class {
6789
7338
  }
6790
7339
  };
6791
7340
 
6792
- // src/shared/d1-session.repository.ts
7341
+ // src/shared/db/repositories/d1-session.repository.ts
6793
7342
  function rowToRecord2(row) {
6794
7343
  return {
6795
7344
  id: row.id,
@@ -6848,7 +7397,7 @@ var D1SessionRepository = class {
6848
7397
  }
6849
7398
  };
6850
7399
 
6851
- // src/shared/d1-password-reset-token.repository.ts
7400
+ // src/shared/db/repositories/d1-password-reset-token.repository.ts
6852
7401
  var D1PasswordResetTokenRepository = class {
6853
7402
  constructor(db, idGenerator) {
6854
7403
  this.db = db;
@@ -6878,7 +7427,7 @@ var D1PasswordResetTokenRepository = class {
6878
7427
  }
6879
7428
  };
6880
7429
 
6881
- // src/shared/d1-activity-log.repository.ts
7430
+ // src/shared/db/repositories/d1-activity-log.repository.ts
6882
7431
  var D1ActivityLogRepository = class {
6883
7432
  constructor(database) {
6884
7433
  this.database = database;
@@ -6941,7 +7490,7 @@ function parseDetails(raw) {
6941
7490
  }
6942
7491
  }
6943
7492
 
6944
- // src/shared/d1-notification.repository.ts
7493
+ // src/shared/db/repositories/d1-notification.repository.ts
6945
7494
  var D1NotificationRepository = class {
6946
7495
  constructor(database, clock, idGenerator) {
6947
7496
  this.database = database;
@@ -7005,7 +7554,7 @@ function mapRowToRecord2(row) {
7005
7554
  };
7006
7555
  }
7007
7556
 
7008
- // src/shared/d1-widget.repository.ts
7557
+ // src/shared/db/repositories/d1-widget.repository.ts
7009
7558
  import {
7010
7559
  isDateRange
7011
7560
  } from "@beechcms/core";
@@ -7079,9 +7628,10 @@ var D1WidgetRepository = class {
7079
7628
  const bindings = [];
7080
7629
  const trimmedSearch = options.search?.trim() ?? "";
7081
7630
  if (trimmedSearch.length > 0) {
7631
+ const escapedSearch = trimmedSearch.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
7082
7632
  const displayColumn = this.resolveColumnExpression(seed, seed.displayNameAlias);
7083
- conditions.push(`${displayColumn} LIKE ?`);
7084
- bindings.push(`%${trimmedSearch}%`);
7633
+ conditions.push(`${displayColumn} LIKE ? ESCAPE '\\'`);
7634
+ bindings.push(`%${escapedSearch}%`);
7085
7635
  }
7086
7636
  for (const filter of options.filters ?? []) {
7087
7637
  this.appendFilterCondition(seed, filter, conditions, bindings);
@@ -7282,7 +7832,7 @@ var D1WidgetRepository = class {
7282
7832
  }
7283
7833
  };
7284
7834
 
7285
- // src/shared/d1-search.repository.ts
7835
+ // src/shared/db/repositories/d1-search.repository.ts
7286
7836
  var EMPTY_QUERY_ERROR = "EMPTY_QUERY";
7287
7837
  var D1SearchRepository = class {
7288
7838
  constructor(database) {
@@ -7342,7 +7892,7 @@ function mapFtsRowToResultRow(row) {
7342
7892
  };
7343
7893
  }
7344
7894
 
7345
- // src/shared/d1-analytics.repository.ts
7895
+ // src/shared/db/repositories/d1-analytics.repository.ts
7346
7896
  var SECONDS_PER_DAY2 = 86400;
7347
7897
  var D1AnalyticsRepository = class {
7348
7898
  constructor(database, clock) {
@@ -7394,7 +7944,7 @@ var D1AnalyticsRepository = class {
7394
7944
  }
7395
7945
  };
7396
7946
 
7397
- // src/shared/d1-content-scan.repository.ts
7947
+ // src/shared/db/repositories/d1-content-scan.repository.ts
7398
7948
  var D1ContentScanRepository = class {
7399
7949
  constructor(db) {
7400
7950
  this.db = db;
@@ -7423,7 +7973,7 @@ var D1ContentScanRepository = class {
7423
7973
  }
7424
7974
  };
7425
7975
 
7426
- // src/shared/site-settings.repository.d1.ts
7976
+ // src/shared/db/repositories/site-settings.repository.d1.ts
7427
7977
  var DEFAULTS = {
7428
7978
  siteTitle: "Beech CMS",
7429
7979
  defaultLanguage: "en",
@@ -7463,7 +8013,7 @@ var D1SiteSettingsRepository = class {
7463
8013
  }
7464
8014
  };
7465
8015
 
7466
- // src/shared/demo-data-sql.ts
8016
+ // src/shared/db/migrations/demo-data-sql.ts
7467
8017
  var DEMO_DATA_SQL = `
7468
8018
  INSERT OR IGNORE INTO content_clienti (id, slug, status, name, email, company, tier, account_status, mrr, created_at, updated_at)
7469
8019
  VALUES
@@ -8442,7 +8992,7 @@ VALUES
8442
8992
 
8443
8993
  `;
8444
8994
 
8445
- // src/shared/demo-data.repository.d1.ts
8995
+ // src/shared/db/repositories/demo-data.repository.d1.ts
8446
8996
  var D1DemoDataRepository = class {
8447
8997
  constructor(db) {
8448
8998
  this.db = db;
@@ -8454,7 +9004,7 @@ var D1DemoDataRepository = class {
8454
9004
  }
8455
9005
  };
8456
9006
 
8457
- // src/shared/d1-setup-checklist.repository.ts
9007
+ // src/shared/db/repositories/d1-setup-checklist.repository.ts
8458
9008
  var D1SetupChecklistRepository = class {
8459
9009
  constructor(db) {
8460
9010
  this.db = db;
@@ -8474,7 +9024,8 @@ var D1SetupChecklistRepository = class {
8474
9024
  }
8475
9025
  };
8476
9026
 
8477
- // src/shared/seed-layout.repository.d1.ts
9027
+ // src/shared/db/repositories/seed-layout.repository.d1.ts
9028
+ import { seedViewConfigSchema as seedViewConfigSchema2 } from "@beechcms/core";
8478
9029
  var D1SeedLayoutRepository = class {
8479
9030
  constructor(db) {
8480
9031
  this.db = db;
@@ -8516,9 +9067,25 @@ var D1SeedLayoutRepository = class {
8516
9067
  async remove(slug) {
8517
9068
  await this.db.prepare("DELETE FROM seed_layouts WHERE slug = ?").bind(slug).run();
8518
9069
  }
9070
+ async getViewConfig(slug) {
9071
+ const row = await this.db.prepare("SELECT view_config FROM seed_layouts WHERE slug = ?").bind(slug).first();
9072
+ if (!row?.view_config) return null;
9073
+ return seedViewConfigSchema2.parse(JSON.parse(row.view_config));
9074
+ }
9075
+ async setViewConfig(slug, config, updatedBy) {
9076
+ const json = JSON.stringify(config);
9077
+ await this.db.prepare(`
9078
+ INSERT INTO seed_layouts (slug, layout, view_config, updated_at, updated_by)
9079
+ VALUES (?, '{}', ?, unixepoch(), ?)
9080
+ ON CONFLICT(slug) DO UPDATE SET
9081
+ view_config = excluded.view_config,
9082
+ updated_at = excluded.updated_at,
9083
+ updated_by = excluded.updated_by
9084
+ `).bind(slug, json, updatedBy).run();
9085
+ }
8519
9086
  };
8520
9087
 
8521
- // src/shared/dashboard-layout.repository.d1.ts
9088
+ // src/shared/db/repositories/dashboard-layout.repository.d1.ts
8522
9089
  var D1DashboardLayoutRepository = class {
8523
9090
  constructor(db) {
8524
9091
  this.db = db;
@@ -8559,7 +9126,7 @@ var D1DashboardLayoutRepository = class {
8559
9126
  }
8560
9127
  };
8561
9128
 
8562
- // src/shared/seed.repository.d1.ts
9129
+ // src/shared/db/repositories/seed.repository.d1.ts
8563
9130
  var D1SeedRepository = class {
8564
9131
  constructor(db) {
8565
9132
  this.db = db;
@@ -8634,7 +9201,7 @@ var D1SeedRepository = class {
8634
9201
  }
8635
9202
  };
8636
9203
 
8637
- // src/shared/schema-mutator.d1.ts
9204
+ // src/shared/db/migrations/schema-mutator.d1.ts
8638
9205
  var D1SchemaMutator = class _D1SchemaMutator {
8639
9206
  constructor(db) {
8640
9207
  this.db = db;
@@ -8677,13 +9244,59 @@ var D1SchemaMutator = class _D1SchemaMutator {
8677
9244
  if (statements.length === 0) return;
8678
9245
  await this.db.batch(statements.map((s) => this.db.prepare(s)));
8679
9246
  }
9247
+ async fetchRows(table, columns) {
9248
+ _D1SchemaMutator.assertIdentifier(table);
9249
+ for (const col of columns) _D1SchemaMutator.assertIdentifier(col);
9250
+ const colList = columns.join(", ");
9251
+ const rs = await this.db.prepare(`SELECT ${colList} FROM ${table}`).all();
9252
+ return rs.results ?? [];
9253
+ }
9254
+ };
9255
+
9256
+ // src/shared/db/repositories/kanban-position.repository.d1.ts
9257
+ var D1KanbanPositionRepository = class {
9258
+ constructor(db) {
9259
+ this.db = db;
9260
+ }
9261
+ db;
9262
+ async getColumn(seedSlug, axisBranchId) {
9263
+ const rs = await this.db.prepare("SELECT entry_id, position FROM kanban_positions WHERE seed_slug = ? AND axis_branch_id = ?").bind(seedSlug, axisBranchId).all();
9264
+ const map = /* @__PURE__ */ new Map();
9265
+ for (const row of rs.results ?? []) {
9266
+ map.set(row.entry_id, row.position);
9267
+ }
9268
+ return map;
9269
+ }
9270
+ async setPosition(seedSlug, entryId, axisBranchId, position) {
9271
+ await this.db.prepare(`
9272
+ INSERT INTO kanban_positions (seed_slug, entry_id, axis_branch_id, position, updated_at)
9273
+ VALUES (?, ?, ?, ?, unixepoch())
9274
+ ON CONFLICT(seed_slug, entry_id, axis_branch_id)
9275
+ DO UPDATE SET position = excluded.position, updated_at = excluded.updated_at
9276
+ `).bind(seedSlug, entryId, axisBranchId, position).run();
9277
+ }
9278
+ async remove(seedSlug, entryId, axisBranchId) {
9279
+ await this.db.prepare("DELETE FROM kanban_positions WHERE seed_slug = ? AND entry_id = ? AND axis_branch_id = ?").bind(seedSlug, entryId, axisBranchId).run();
9280
+ }
9281
+ async rebalance(seedSlug, axisBranchId, ordered) {
9282
+ if (ordered.length === 0) return;
9283
+ const stmts = ordered.map(
9284
+ (r) => this.db.prepare(`
9285
+ INSERT INTO kanban_positions (seed_slug, entry_id, axis_branch_id, position, updated_at)
9286
+ VALUES (?, ?, ?, ?, unixepoch())
9287
+ ON CONFLICT(seed_slug, entry_id, axis_branch_id)
9288
+ DO UPDATE SET position = excluded.position, updated_at = excluded.updated_at
9289
+ `).bind(seedSlug, r.entryId, axisBranchId, r.position)
9290
+ );
9291
+ await this.db.batch(stmts);
9292
+ }
8680
9293
  };
8681
9294
 
8682
9295
  // src/middleware/repository.middleware.ts
8683
9296
  import { SystemClock as SystemClock2, SystemIdGenerator } from "@beechcms/core";
8684
9297
  import { NoOpScheduler } from "@beechcms/core";
8685
9298
 
8686
- // src/shared/automations.repository.d1.ts
9299
+ // src/shared/db/repositories/automations.repository.d1.ts
8687
9300
  var D1AutomationRepository = class {
8688
9301
  constructor(db) {
8689
9302
  this.db = db;
@@ -8769,7 +9382,7 @@ function rowToAutomation(row) {
8769
9382
  };
8770
9383
  }
8771
9384
 
8772
- // src/shared/execution-context-scheduler.ts
9385
+ // src/shared/services/scheduler/execution-context-scheduler.ts
8773
9386
  var ExecutionContextScheduler = class {
8774
9387
  constructor(ctx) {
8775
9388
  this.ctx = ctx;
@@ -8828,6 +9441,7 @@ var repositoryMiddleware = (overrides) => {
8828
9441
  context.set("seedRepository", overrides?.seedRepository ?? new D1SeedRepository(database));
8829
9442
  context.set("schemaMutator", overrides?.schemaMutator ?? new D1SchemaMutator(database));
8830
9443
  context.set("dashboardLayoutRepository", overrides?.dashboardLayoutRepository ?? new D1DashboardLayoutRepository(database));
9444
+ context.set("kanbanPositionRepository", overrides?.kanbanPositionRepository ?? new D1KanbanPositionRepository(database));
8831
9445
  await next();
8832
9446
  });
8833
9447
  };
@@ -8835,7 +9449,7 @@ var repositoryMiddleware = (overrides) => {
8835
9449
  // src/middleware/seed-registry.middleware.ts
8836
9450
  import { createMiddleware as createMiddleware3 } from "hono/factory";
8837
9451
 
8838
- // src/shared/seed-registry-cache.ts
9452
+ // src/shared/services/cache/seed-registry-cache.ts
8839
9453
  import { SeedRegistry, buildBackrefMap } from "@beechcms/core";
8840
9454
  var cache = null;
8841
9455
  var TTL_MS = 5e3;
@@ -8895,30 +9509,12 @@ var S3Bucket = class {
8895
9509
  const command = new PutObjectCommand({
8896
9510
  Bucket: this.bucketName,
8897
9511
  Key: key,
8898
- Body: body instanceof ReadableStream ? await this.streamToUint8Array(body) : new Uint8Array(body),
9512
+ Body: body instanceof ReadableStream ? body : new Uint8Array(body),
8899
9513
  ContentType: options?.contentType,
8900
9514
  Metadata: options?.metadata
8901
9515
  });
8902
9516
  await this.client.send(command);
8903
9517
  }
8904
- async streamToUint8Array(stream) {
8905
- const reader = stream.getReader();
8906
- const chunks = [];
8907
- let totalLength = 0;
8908
- while (true) {
8909
- const { done, value } = await reader.read();
8910
- if (done) break;
8911
- chunks.push(value);
8912
- totalLength += value.length;
8913
- }
8914
- const result = new Uint8Array(totalLength);
8915
- let offset = 0;
8916
- for (const chunk of chunks) {
8917
- result.set(chunk, offset);
8918
- offset += chunk.length;
8919
- }
8920
- return result;
8921
- }
8922
9518
  async get(key) {
8923
9519
  try {
8924
9520
  const command = new GetObjectCommand({
@@ -8967,10 +9563,11 @@ var S3Bucket = class {
8967
9563
  }
8968
9564
  }
8969
9565
  getUrl(key) {
9566
+ const encodedKey = key.split("/").map(encodeURIComponent).join("/");
8970
9567
  if (this.cdnUrl) {
8971
- return `${this.cdnUrl}/${encodeURIComponent(key)}`;
9568
+ return `${this.cdnUrl}/${encodedKey}`;
8972
9569
  }
8973
- return `${this.baseUrl}/api/media/${encodeURIComponent(key)}`;
9570
+ return `${this.baseUrl}/api/media/${encodedKey}`;
8974
9571
  }
8975
9572
  async getTotalSize() {
8976
9573
  let total = 0;
@@ -8992,7 +9589,8 @@ var S3Bucket = class {
8992
9589
  const command = new PutObjectCommand({
8993
9590
  Bucket: this.bucketName,
8994
9591
  Key: key,
8995
- ContentType: options.contentType
9592
+ ContentType: options.contentType,
9593
+ ContentLength: options.contentLength
8996
9594
  });
8997
9595
  return getSignedUrl(this.client, command, { expiresIn: options.expiresIn });
8998
9596
  }
@@ -9022,9 +9620,21 @@ var S3Bucket = class {
9022
9620
  };
9023
9621
 
9024
9622
  // src/shared/storage/factory.ts
9623
+ import { HTTPException as HTTPException2 } from "hono/http-exception";
9025
9624
  var NullBucket = class {
9026
9625
  fail() {
9027
- throw new Error("Storage not configured. Set R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME.");
9626
+ throw new HTTPException2(503, {
9627
+ res: new Response(
9628
+ JSON.stringify({
9629
+ error: "storage_not_configured",
9630
+ message: "Storage is not configured. Please set R2 credentials or run MinIO."
9631
+ }),
9632
+ {
9633
+ status: 503,
9634
+ headers: { "Content-Type": "application/json" }
9635
+ }
9636
+ )
9637
+ });
9028
9638
  }
9029
9639
  put() {
9030
9640
  return this.fail();
@@ -9081,7 +9691,7 @@ var storageMiddleware = (overrides) => {
9081
9691
  });
9082
9692
  };
9083
9693
 
9084
- // src/shared/in-memory-seed.repository.ts
9694
+ // src/shared/db/repositories/in-memory-seed.repository.ts
9085
9695
  var InMemorySeedRepository = class {
9086
9696
  seeds;
9087
9697
  version = 1;
@@ -9127,7 +9737,7 @@ var InMemorySeedRepository = class {
9127
9737
  import { createMiddleware as createMiddleware5 } from "hono/factory";
9128
9738
  import { SystemClock as SystemClock3 } from "@beechcms/core";
9129
9739
 
9130
- // src/auth/bcrypt-hash-provider.ts
9740
+ // src/auth/providers/hash.provider.ts
9131
9741
  import bcrypt from "bcryptjs";
9132
9742
  var BCRYPT_SALT_ROUNDS = 10;
9133
9743
  var BcryptHashProvider = class {
@@ -9143,7 +9753,7 @@ var BcryptHashProvider = class {
9143
9753
  }
9144
9754
  };
9145
9755
 
9146
- // src/auth/jose-token-service.ts
9756
+ // src/auth/providers/jwt-token.service.ts
9147
9757
  import { SignJWT, jwtVerify } from "jose";
9148
9758
  var DEFAULT_TOKEN_TTL_SECONDS = 900;
9149
9759
  var DEFAULT_ALGORITHM = "HS256";
@@ -9175,8 +9785,12 @@ var JoseTokenService = class {
9175
9785
  const { payload } = await jwtVerify(token, this.secretBytes, {
9176
9786
  algorithms: [algorithm],
9177
9787
  issuer: this.config.issuer,
9178
- audience: this.config.audience
9788
+ audience: this.config.audience,
9789
+ currentDate: new Date(this.clock.nowSeconds() * 1e3)
9179
9790
  });
9791
+ if (typeof payload.sub !== "string" || payload.sub === "") {
9792
+ return null;
9793
+ }
9180
9794
  return payload;
9181
9795
  } catch {
9182
9796
  return null;
@@ -9214,13 +9828,23 @@ import { createMiddleware as createMiddleware6 } from "hono/factory";
9214
9828
 
9215
9829
  // src/rate-limit/cloudflare-rate-limiter.ts
9216
9830
  var CloudflareRateLimiter = class {
9217
- constructor(binding) {
9831
+ constructor(binding, options = {}) {
9218
9832
  this.binding = binding;
9833
+ this.options = options;
9219
9834
  }
9220
9835
  binding;
9836
+ options;
9221
9837
  async checkLimit(key) {
9222
- const { success } = await this.binding.limit({ key });
9223
- return { isAllowed: success };
9838
+ try {
9839
+ const res = await this.binding.limit({ key });
9840
+ return {
9841
+ isAllowed: res.success,
9842
+ retryAfterSeconds: res.retryAfterSeconds ?? res.retryAfter
9843
+ };
9844
+ } catch (error) {
9845
+ console.warn(`Rate limiter binding error for key "${key}":`, error);
9846
+ return { isAllowed: !this.options.failClosed };
9847
+ }
9224
9848
  }
9225
9849
  };
9226
9850
 
@@ -9235,13 +9859,20 @@ var NoOpRateLimiter = class {
9235
9859
  var NO_OP = new NoOpRateLimiter();
9236
9860
  function buildDefaultRegistry(env) {
9237
9861
  const isDev = env.ENV === "development" && !(typeof globalThis.process !== "undefined" && globalThis.process.env?.VITEST);
9862
+ const isProd = env.ENV === "production";
9863
+ const getLimiter = (bindingName, binding, options) => {
9864
+ if (isProd && !binding) {
9865
+ throw new Error(`Missing rate limiter binding: ${bindingName} is required in production environment.`);
9866
+ }
9867
+ return !isDev && binding ? new CloudflareRateLimiter(binding, options) : NO_OP;
9868
+ };
9238
9869
  const limiters = {
9239
- login: !isDev && env.LOGIN_RATE_LIMITER ? new CloudflareRateLimiter(env.LOGIN_RATE_LIMITER) : NO_OP,
9240
- tokenRefresh: !isDev && env.REFRESH_RATE_LIMITER ? new CloudflareRateLimiter(env.REFRESH_RATE_LIMITER) : NO_OP,
9241
- forgotPassword: !isDev && env.FORGOT_PASSWORD_RATE_LIMITER ? new CloudflareRateLimiter(env.FORGOT_PASSWORD_RATE_LIMITER) : NO_OP,
9242
- resetPassword: !isDev && env.RESET_PASSWORD_RATE_LIMITER ? new CloudflareRateLimiter(env.RESET_PASSWORD_RATE_LIMITER) : NO_OP,
9243
- publicApiRead: !isDev && env.PUBLIC_READ_RATE_LIMITER ? new CloudflareRateLimiter(env.PUBLIC_READ_RATE_LIMITER) : NO_OP,
9244
- publicApiWrite: !isDev && env.PUBLIC_WRITE_RATE_LIMITER ? new CloudflareRateLimiter(env.PUBLIC_WRITE_RATE_LIMITER) : NO_OP
9870
+ login: getLimiter("LOGIN_RATE_LIMITER", env.LOGIN_RATE_LIMITER, { failClosed: true }),
9871
+ tokenRefresh: getLimiter("REFRESH_RATE_LIMITER", env.REFRESH_RATE_LIMITER, { failClosed: true }),
9872
+ forgotPassword: getLimiter("FORGOT_PASSWORD_RATE_LIMITER", env.FORGOT_PASSWORD_RATE_LIMITER, { failClosed: true }),
9873
+ resetPassword: getLimiter("RESET_PASSWORD_RATE_LIMITER", env.RESET_PASSWORD_RATE_LIMITER, { failClosed: true }),
9874
+ publicApiRead: getLimiter("PUBLIC_READ_RATE_LIMITER", env.PUBLIC_READ_RATE_LIMITER, { failClosed: false }),
9875
+ publicApiWrite: getLimiter("PUBLIC_WRITE_RATE_LIMITER", env.PUBLIC_WRITE_RATE_LIMITER, { failClosed: false })
9245
9876
  };
9246
9877
  return { getLimiter: (name) => limiters[name] };
9247
9878
  }
@@ -9257,7 +9888,7 @@ var rateLimiterMiddleware = (overrides) => {
9257
9888
  import { createMiddleware as createMiddleware7 } from "hono/factory";
9258
9889
  import { SystemClock as SystemClock4, SystemIdGenerator as SystemIdGenerator2 } from "@beechcms/core";
9259
9890
 
9260
- // src/shared/d1-activity-logger.ts
9891
+ // src/shared/services/activity-log/d1-activity-logger.ts
9261
9892
  var ACTIVITY_LOG_INSERT_SQL = `INSERT INTO activity_logs
9262
9893
  (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
9263
9894
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
@@ -9302,7 +9933,7 @@ var D1ActivityLogger = class {
9302
9933
  }
9303
9934
  };
9304
9935
 
9305
- // src/shared/background-notification-service.ts
9936
+ // src/shared/services/notification/background-notification-service.ts
9306
9937
  var DEFAULT_NOTIFICATION_TYPE = "info";
9307
9938
  var BackgroundNotificationService = class {
9308
9939
  constructor(notificationRepository, scheduleBackgroundTask) {
@@ -9332,7 +9963,7 @@ var BackgroundNotificationService = class {
9332
9963
  }
9333
9964
  };
9334
9965
 
9335
- // src/shared/qstash-notification-service.ts
9966
+ // src/shared/services/notification/qstash-notification-service.ts
9336
9967
  import { Client } from "@upstash/qstash";
9337
9968
  var QStashNotificationService = class {
9338
9969
  constructor(token, appUrl, scheduleBackgroundTask, qstashUrl) {
@@ -9405,7 +10036,7 @@ var observabilityMiddleware = (overrides) => {
9405
10036
  import { createMiddleware as createMiddleware8 } from "hono/factory";
9406
10037
  import { SystemClock as SystemClock5, SystemIdGenerator as SystemIdGenerator3 } from "@beechcms/core";
9407
10038
 
9408
- // src/shared/cloudflare-queue-service.ts
10039
+ // src/shared/services/queue/cloudflare-queue-service.ts
9409
10040
  var CloudflareQueueService = class {
9410
10041
  constructor(queue) {
9411
10042
  this.queue = queue;
@@ -9420,7 +10051,7 @@ var CloudflareQueueService = class {
9420
10051
  }
9421
10052
  };
9422
10053
 
9423
- // src/shared/in-memory-queue-service.ts
10054
+ // src/shared/services/queue/in-memory-queue-service.ts
9424
10055
  var InMemoryQueueService = class {
9425
10056
  constructor(jobs, context, scheduleBackgroundTask) {
9426
10057
  this.jobs = jobs;
@@ -9547,15 +10178,15 @@ function createBeechApp(config) {
9547
10178
  try {
9548
10179
  const originUrl = new URL(origin);
9549
10180
  const requestUrl = new URL(context.req.url);
9550
- if (originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
10181
+ if (originUrl.protocol === requestUrl.protocol && originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
9551
10182
  return origin;
9552
10183
  }
9553
10184
  } catch {
9554
10185
  }
9555
10186
  return null;
9556
10187
  },
9557
- allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
9558
- allowHeaders: ["Content-Type", "Authorization", "X-API-Key"],
10188
+ allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
10189
+ allowHeaders: ["Content-Type", "Authorization", "X-API-Key", "Idempotency-Key"],
9559
10190
  credentials: true
9560
10191
  })(context, next);
9561
10192
  });
@@ -9601,7 +10232,13 @@ function createBeechApp(config) {
9601
10232
  if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400);
9602
10233
  const clientIp = getClientIp(context.req);
9603
10234
  const loginRateLimit = await context.get("rateLimiters").getLimiter("login").checkLimit(clientIp);
9604
- if (!loginRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429);
10235
+ if (!loginRateLimit.isAllowed) {
10236
+ const headers = {};
10237
+ if (loginRateLimit.retryAfterSeconds !== void 0) {
10238
+ headers["Retry-After"] = String(loginRateLimit.retryAfterSeconds);
10239
+ }
10240
+ return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429, headers);
10241
+ }
9605
10242
  const user = await context.get("userRepository").findByEmail(email);
9606
10243
  const hashToCompare = user?.passwordHash ?? DUMMY_PASSWORD_HASH;
9607
10244
  const isValid = await verifyPassword(password, hashToCompare, context.get("hashProvider"));
@@ -9626,7 +10263,13 @@ function createBeechApp(config) {
9626
10263
  try {
9627
10264
  const refreshClientIp = getClientIp(context.req);
9628
10265
  const refreshRateLimit = await context.get("rateLimiters").getLimiter("tokenRefresh").checkLimit(refreshClientIp);
9629
- if (!refreshRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429);
10266
+ if (!refreshRateLimit.isAllowed) {
10267
+ const headers = {};
10268
+ if (refreshRateLimit.retryAfterSeconds !== void 0) {
10269
+ headers["Retry-After"] = String(refreshRateLimit.retryAfterSeconds);
10270
+ }
10271
+ return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429, headers);
10272
+ }
9630
10273
  const refreshToken = getCookie(context, "refresh_token");
9631
10274
  if (!refreshToken) return context.json({ error: "Refresh token missing" }, 401);
9632
10275
  const nowSeconds = SystemClock6.nowSeconds();
@@ -9647,10 +10290,19 @@ function createBeechApp(config) {
9647
10290
  tokenHash: newRefreshTokenHash,
9648
10291
  expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY3
9649
10292
  });
9650
- const revoked = await context.get("sessionRepository").revokeByHash(tokenHash, nowSeconds);
9651
- if (!revoked) return context.json({ error: "Invalid refresh token" }, 401);
9652
- setCookie(context, "refresh_token", newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)));
9653
- return context.json({ token: newAccessToken, expiresIn: "15m" }, 200);
10293
+ try {
10294
+ const revoked = await context.get("sessionRepository").revokeByHash(tokenHash, nowSeconds);
10295
+ if (!revoked) {
10296
+ await context.get("sessionRepository").revokeByHash(newRefreshTokenHash, nowSeconds);
10297
+ return context.json({ error: "Invalid refresh token" }, 401);
10298
+ }
10299
+ setCookie(context, "refresh_token", newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)));
10300
+ return context.json({ token: newAccessToken, expiresIn: "15m" }, 200);
10301
+ } catch (error) {
10302
+ await context.get("sessionRepository").revokeByHash(newRefreshTokenHash, nowSeconds).catch(() => {
10303
+ });
10304
+ throw error;
10305
+ }
9654
10306
  } catch (error) {
9655
10307
  return handleAuthError(context, error, "Refresh");
9656
10308
  }
@@ -9692,7 +10344,7 @@ function createBeechApp(config) {
9692
10344
  apiPublic.route("/", publicRoutes);
9693
10345
  app.route("/api/v1/public", apiPublic);
9694
10346
  app.route("/api/webhooks", webhooksApp);
9695
- app.get("/api/media/:key", (context) => serveMediaHandler(context));
10347
+ app.get("/api/media/:key{.+}", (context) => serveMediaHandler(context));
9696
10348
  if (config.customRoutes) {
9697
10349
  const publicRouter = new Hono19();
9698
10350
  const protectedRouter = new Hono19();