@beechcms/api 0.6.0-preview.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/assets/dashboard/assets/chart-FUiy4I7R.js +39 -0
  2. package/assets/dashboard/assets/index-BlehzIfd.js +644 -0
  3. package/assets/dashboard/assets/index-Dve7eEqA.css +1 -0
  4. package/assets/dashboard/assets/pie-chart-recharts-iaYz4FeR.js +1 -0
  5. package/assets/dashboard/assets/timeseries-chart-recharts-C8Lcylom.js +1 -0
  6. package/assets/dashboard/index.html +3 -4
  7. package/dist/auth/constants.d.ts +5 -5
  8. package/dist/factory.d.ts +7 -1
  9. package/dist/features/automations/{automations.handler.d.ts → api/automations.handler.d.ts} +1 -1
  10. package/dist/features/automations/{automation-runner.utils.d.ts → engine/automation-runner.utils.d.ts} +1 -1
  11. package/dist/features/automations/{action-executors → executors}/edit-field.executor.d.ts +1 -1
  12. package/dist/features/automations/{action-executors → executors}/index.d.ts +1 -1
  13. package/dist/features/automations/{action-executors → executors}/send-mail.executor.d.ts +1 -1
  14. package/dist/features/automations/{action-executors → executors}/set-variable.executor.d.ts +1 -1
  15. package/dist/features/automations/{action-executors → executors}/webhook.executor.d.ts +1 -1
  16. package/dist/features/automations/{when-evaluator.d.ts → filters/when-evaluator.d.ts} +1 -1
  17. package/dist/features/automations/index.d.ts +7 -7
  18. package/dist/features/content/constants.d.ts +10 -10
  19. package/dist/features/content/handlers/kanban-move.d.ts +3 -0
  20. package/dist/features/content/handlers/kanban-position.d.ts +3 -0
  21. package/dist/features/content/handlers/view-config.d.ts +4 -0
  22. package/dist/{search.d.ts → features/search/search.d.ts} +1 -1
  23. package/dist/features/upload/index.d.ts +9 -0
  24. package/dist/{widget.d.ts → features/widget/widget.d.ts} +1 -1
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.js +1126 -421
  27. package/dist/{middleware.d.ts → middleware/auth.middleware.d.ts} +1 -1
  28. package/dist/middleware/queue.middleware.d.ts +11 -0
  29. package/dist/middleware/repository.middleware.d.ts +2 -1
  30. package/dist/public/access-policy.d.ts +1 -1
  31. package/dist/public/problem-details.d.ts +1 -0
  32. package/dist/public/public-errors.d.ts +4 -4
  33. package/dist/rate-limit/cloudflare-rate-limiter.d.ts +4 -1
  34. package/dist/rate-limit/in-memory-rate-limiter.d.ts +2 -1
  35. package/dist/shared/cloudflare-queue-service.d.ts +11 -0
  36. package/dist/shared/{schema-mutator.d1.d.ts → db/migrations/schema-mutator.d1.d.ts} +1 -0
  37. package/dist/shared/db/repositories/content.repository.d1.d.ts +259 -0
  38. package/dist/shared/db/repositories/kanban-position.repository.d1.d.ts +9 -0
  39. package/dist/shared/{seed-layout.repository.d1.d.ts → db/repositories/seed-layout.repository.d1.d.ts} +3 -1
  40. package/dist/shared/email/email.provider.d.ts +1 -0
  41. package/dist/{features → shared}/email/email.types.d.ts +1 -16
  42. package/dist/{features → shared}/email/providers/resend.d.ts +1 -2
  43. package/dist/{features → shared}/email/providers/smtp.d.ts +1 -2
  44. package/dist/shared/in-memory-queue-service.d.ts +16 -0
  45. package/dist/shared/jobs/queue-consumer.d.ts +9 -0
  46. package/dist/shared/queue-consumer.d.ts +9 -0
  47. package/dist/shared/services/queue/cloudflare-queue-service.d.ts +11 -0
  48. package/dist/shared/services/queue/in-memory-queue-service.d.ts +16 -0
  49. package/dist/shared/storage/s3-bucket.d.ts +0 -1
  50. package/dist/shared/storage/upload.d.ts +7 -0
  51. package/dist/{media-utils.d.ts → shared/utils/media-utils.d.ts} +4 -2
  52. package/dist/types.d.ts +95 -2
  53. package/package.json +6 -4
  54. package/assets/dashboard/assets/chart-Cws4Yj6K.js +0 -39
  55. package/assets/dashboard/assets/index-BjPpppHe.css +0 -1
  56. package/assets/dashboard/assets/index-CrW-zhkA.js +0 -642
  57. package/assets/dashboard/assets/pie-chart-recharts-CAxoDQp2.js +0 -1
  58. package/assets/dashboard/assets/timeseries-chart-recharts-CixDW-LM.js +0 -1
  59. package/dist/features/email/email.provider.d.ts +0 -37
  60. package/dist/shared/content.repository.d1.d.ts +0 -68
  61. package/dist/upload.d.ts +0 -16
  62. package/dist/auth/{bcrypt-hash-provider.d.ts → providers/hash.provider.d.ts} +0 -0
  63. package/dist/auth/{jose-token-service.d.ts → providers/jwt-token.service.d.ts} +0 -0
  64. package/dist/auth/{login.d.ts → utils/login-helpers.d.ts} +0 -0
  65. package/dist/auth/{refresh.d.ts → utils/refresh-token.d.ts} +0 -0
  66. package/dist/features/automations/{automations.schema.d.ts → api/automations.schema.d.ts} +19 -19
  67. /package/dist/features/automations/{automation-runner.d.ts → engine/automation-runner.d.ts} +0 -0
  68. /package/dist/features/automations/{cron-runner.d.ts → engine/cron-runner.d.ts} +0 -0
  69. /package/dist/features/automations/{cron-runner.utils.d.ts → engine/cron-runner.utils.d.ts} +0 -0
  70. /package/dist/features/automations/{context-resolver.d.ts → evaluator/context-resolver.d.ts} +0 -0
  71. /package/dist/features/automations/{template-grammar.d.ts → evaluator/template-grammar.d.ts} +0 -0
  72. /package/dist/features/automations/{var-access-resolver.d.ts → evaluator/var-access-resolver.d.ts} +0 -0
  73. /package/dist/features/automations/{action-executors → executors}/create-entry.executor.d.ts +0 -0
  74. /package/dist/features/automations/{filter-translation.d.ts → filters/filter-translation.d.ts} +0 -0
  75. /package/dist/features/automations/{when-pushdown.d.ts → filters/when-pushdown.d.ts} +0 -0
  76. /package/dist/{search-utils.d.ts → features/search/search-utils.d.ts} +0 -0
  77. /package/dist/shared/{demo-data-sql.d.ts → db/migrations/demo-data-sql.d.ts} +0 -0
  78. /package/dist/shared/{automations.repository.d1.d.ts → db/repositories/automations.repository.d1.d.ts} +0 -0
  79. /package/dist/shared/{base.repository.d1.d.ts → db/repositories/base.repository.d1.d.ts} +0 -0
  80. /package/dist/shared/{d1-activity-log.repository.d.ts → db/repositories/d1-activity-log.repository.d.ts} +0 -0
  81. /package/dist/shared/{d1-analytics.repository.d.ts → db/repositories/d1-analytics.repository.d.ts} +0 -0
  82. /package/dist/shared/{d1-content-scan.repository.d.ts → db/repositories/d1-content-scan.repository.d.ts} +0 -0
  83. /package/dist/shared/{d1-notification.repository.d.ts → db/repositories/d1-notification.repository.d.ts} +0 -0
  84. /package/dist/shared/{d1-password-reset-token.repository.d.ts → db/repositories/d1-password-reset-token.repository.d.ts} +0 -0
  85. /package/dist/shared/{d1-search.repository.d.ts → db/repositories/d1-search.repository.d.ts} +0 -0
  86. /package/dist/shared/{d1-session.repository.d.ts → db/repositories/d1-session.repository.d.ts} +0 -0
  87. /package/dist/shared/{d1-setup-checklist.repository.d.ts → db/repositories/d1-setup-checklist.repository.d.ts} +0 -0
  88. /package/dist/shared/{d1-user.repository.d.ts → db/repositories/d1-user.repository.d.ts} +0 -0
  89. /package/dist/shared/{d1-widget.repository.d.ts → db/repositories/d1-widget.repository.d.ts} +0 -0
  90. /package/dist/shared/{dashboard-layout.repository.d1.d.ts → db/repositories/dashboard-layout.repository.d1.d.ts} +0 -0
  91. /package/dist/shared/{demo-data.repository.d1.d.ts → db/repositories/demo-data.repository.d1.d.ts} +0 -0
  92. /package/dist/shared/{idempotency.repository.d1.d.ts → db/repositories/idempotency.repository.d1.d.ts} +0 -0
  93. /package/dist/shared/{in-memory-seed.repository.d.ts → db/repositories/in-memory-seed.repository.d.ts} +0 -0
  94. /package/dist/shared/{media.repository.d1.d.ts → db/repositories/media.repository.d1.d.ts} +0 -0
  95. /package/dist/shared/{seed.repository.d1.d.ts → db/repositories/seed.repository.d1.d.ts} +0 -0
  96. /package/dist/shared/{site-settings.repository.d1.d.ts → db/repositories/site-settings.repository.d1.d.ts} +0 -0
  97. /package/dist/shared/{system-stats.repository.d1.d.ts → db/repositories/system-stats.repository.d1.d.ts} +0 -0
  98. /package/dist/{features → shared}/email/email.service.d.ts +0 -0
  99. /package/dist/{features → shared}/email/index.d.ts +0 -0
  100. /package/dist/{features → shared}/email/templates/automation-mail.d.ts +0 -0
  101. /package/dist/{features → shared}/email/templates/password-changed.d.ts +0 -0
  102. /package/dist/{features → shared}/email/templates/password-reset.d.ts +0 -0
  103. /package/dist/{features → shared}/email/templates/shell.d.ts +0 -0
  104. /package/dist/shared/{fts-sync.d.ts → jobs/fts-sync.d.ts} +0 -0
  105. /package/dist/shared/{apply-policies.d.ts → policies/apply-policies.d.ts} +0 -0
  106. /package/dist/shared/{d1-activity-logger.d.ts → services/activity-log/d1-activity-logger.d.ts} +0 -0
  107. /package/dist/shared/{in-memory-activity-logger.d.ts → services/activity-log/in-memory-activity-logger.d.ts} +0 -0
  108. /package/dist/shared/{seed-registry-cache.d.ts → services/cache/seed-registry-cache.d.ts} +0 -0
  109. /package/dist/shared/{fixed-clock.d.ts → services/clock/fixed-clock.d.ts} +0 -0
  110. /package/dist/shared/{sequential-id-generator.d.ts → services/id-generator/sequential-id-generator.d.ts} +0 -0
  111. /package/dist/shared/{background-notification-service.d.ts → services/notification/background-notification-service.d.ts} +0 -0
  112. /package/dist/shared/{in-memory-notification-service.d.ts → services/notification/in-memory-notification-service.d.ts} +0 -0
  113. /package/dist/shared/{qstash-notification-service.d.ts → services/notification/qstash-notification-service.d.ts} +0 -0
  114. /package/dist/shared/{execution-context-scheduler.d.ts → services/scheduler/execution-context-scheduler.d.ts} +0 -0
  115. /package/dist/shared/{content-utils.d.ts → utils/content-utils.d.ts} +0 -0
  116. /package/dist/shared/{query-utils.d.ts → utils/query-utils.d.ts} +0 -0
  117. /package/dist/shared/{request-utils.d.ts → utils/request-utils.d.ts} +0 -0
  118. /package/dist/shared/{storage-utils.d.ts → utils/storage-utils.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
  import { Hono as Hono19 } from "hono";
3
3
  import { cors } from "hono/cors";
4
4
  import { getCookie, setCookie, deleteCookie } from "hono/cookie";
5
- import { sha256hex as sha256hex7, SystemClock as SystemClock5, SystemIdGenerator as SystemIdGenerator3 } from "@beechcms/core";
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;
@@ -38,20 +39,20 @@ function parseLoginBody(body) {
38
39
  return { email: email.trim(), 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.trim().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"
@@ -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
  }
@@ -751,7 +786,7 @@ async function updateHandler(context) {
751
786
  detail: "Missing required field: slug"
752
787
  });
753
788
  }
754
- let mergedData = {};
789
+ const mergedData = {};
755
790
  if (Object.keys(bodyForData).length > 0) {
756
791
  const sensitiveAliases = Object.keys(bodyForData).filter((alias) => {
757
792
  const branch = seed.branches.find((b) => b.alias === alias);
@@ -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,156 @@ 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 } 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
+ const entry = await context.get("repository").findById(seed, id);
1179
+ if (!entry) {
1180
+ return publicProblem(context, { type: "content-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.NOT_FOUND });
1181
+ }
1182
+ await context.get("kanbanPositionRepository").setPosition(slug, id, axisBranchId, position);
1183
+ return context.json({ success: true });
1184
+ }
1185
+
1186
+ // src/features/content/handlers/kanban-move.ts
1187
+ import {
1188
+ resolveKanbanConfig as resolveKanbanConfig3,
1189
+ validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload3
1190
+ } from "@beechcms/core";
1191
+ async function kanbanMoveHandler(context) {
1192
+ const slug = context.req.param("slug");
1193
+ const id = context.req.param("id");
1194
+ if (!slug || !id) {
1195
+ return publicProblem(context, { type: "content-invalid-slug-or-id", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID });
1196
+ }
1197
+ const seed = context.get("getSeed")(slug);
1198
+ if (!seed) {
1199
+ return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
1200
+ }
1201
+ let body;
1202
+ try {
1203
+ const raw = await context.req.json();
1204
+ if (typeof raw.position !== "string" || !raw.position || typeof raw.axisBranchId !== "string" || !raw.axisBranchId) {
1205
+ throw new Error("invalid");
1206
+ }
1207
+ body = raw;
1208
+ } catch {
1209
+ return publicProblem(context, { type: "content-invalid-body", title: "Bad Request", status: 400, detail: "position and axisBranchId are required strings" });
1210
+ }
1211
+ const compat = resolveKanbanConfig3(seed);
1212
+ const candidate = compat.candidates.find((c) => c.branchId === body.axisBranchId);
1213
+ if (!compat.compatible || !candidate) {
1214
+ return publicProblem(context, { type: "content-invalid-kanban-axis", title: "Bad Request", status: 400, detail: "axisBranchId is not a valid kanban candidate for this seed" });
1215
+ }
1216
+ const axisBranch = seed.branches.find((b) => b.id === body.axisBranchId);
1217
+ const repository = context.get("repository");
1218
+ let current;
1219
+ try {
1220
+ current = await repository.findById(seed, id);
1221
+ } catch {
1222
+ return publicProblem(context, { type: "content-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.NOT_FOUND });
1223
+ }
1224
+ let patch = null;
1225
+ if (body.axis) {
1226
+ if (body.axis.kind === "scalar") {
1227
+ const rawValue = body.axis.value;
1228
+ const coerced = axisBranch.type === "boolean" && rawValue !== null ? rawValue === "true" : rawValue;
1229
+ patch = { [axisBranch.alias]: coerced };
1230
+ } else {
1231
+ const currentTags = current[axisBranch.alias] ?? [];
1232
+ const { oldValue, newValue } = body.axis;
1233
+ const withoutOld = oldValue !== null ? currentTags.filter((t) => t !== oldValue) : currentTags;
1234
+ const nextTags = newValue !== null ? [...withoutOld, newValue] : withoutOld;
1235
+ patch = { [axisBranch.alias]: nextTags };
1236
+ }
1237
+ const validation = validateAndSanitizeSeedPayload3(seed, patch, {
1238
+ operation: "update",
1239
+ allowNull: true,
1240
+ requireAtLeastOneValidField: true,
1241
+ enforceRequiredFields: false,
1242
+ idGenerator: context.get("idGenerator")
1243
+ });
1244
+ if (validation.details.length > 0) {
1245
+ return publicProblem(context, { type: "content-validation-error", title: "Unprocessable Entity", status: 422, detail: validation.details[0].message });
1246
+ }
1247
+ patch = validation.data;
1248
+ }
1249
+ const jwtPayload = context.get("jwtPayload");
1250
+ await repository.updateWithKanbanPosition(seed, id, patch, body.position, body.axisBranchId, { actor: jwtPayload.sub });
1251
+ return context.json({ success: true });
1252
+ }
1253
+
1254
+ // src/features/content/handlers/view-config.ts
1255
+ import { seedViewConfigSchema, validateCardConfigAgainstSeed } from "@beechcms/core";
1256
+ async function getViewConfigHandler(context) {
1257
+ const slug = context.req.param("slug");
1258
+ if (!slug) {
1259
+ return publicProblem(context, { type: "content-invalid-slug", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG });
1260
+ }
1261
+ const seed = context.get("getSeed")(slug);
1262
+ if (!seed) {
1263
+ return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
1264
+ }
1265
+ const config = await context.get("seedLayoutRepository").getViewConfig(slug);
1266
+ return context.json(config ?? {});
1267
+ }
1268
+ async function putViewConfigHandler(context) {
1269
+ const slug = context.req.param("slug");
1270
+ if (!slug) {
1271
+ return publicProblem(context, { type: "content-invalid-slug", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG });
1272
+ }
1273
+ const seed = context.get("getSeed")(slug);
1274
+ if (!seed) {
1275
+ return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
1276
+ }
1277
+ let body;
1278
+ try {
1279
+ body = await context.req.json();
1280
+ } catch {
1281
+ return publicProblem(context, { type: "content-invalid-body", title: "Bad Request", status: 400, detail: "Invalid JSON body" });
1282
+ }
1283
+ const parsed = seedViewConfigSchema.safeParse(body);
1284
+ if (!parsed.success) {
1285
+ return publicProblem(context, { type: "content-invalid-view-config", title: "Unprocessable Entity", status: 422, detail: parsed.error.issues[0]?.message ?? "Invalid view config" });
1286
+ }
1287
+ if (parsed.data.card) {
1288
+ const { cleaned } = validateCardConfigAgainstSeed(parsed.data.card, seed);
1289
+ parsed.data = { ...parsed.data, card: cleaned };
1290
+ }
1291
+ const jwtPayload = context.get("jwtPayload");
1292
+ await context.get("seedLayoutRepository").setViewConfig(slug, parsed.data, jwtPayload.sub);
1293
+ return context.json({ ok: true });
1294
+ }
1295
+
1242
1296
  // src/features/content/index.ts
1243
- var content = new Hono2();
1297
+ var content = new Hono();
1298
+ content.patch("/:slug/:id/kanban-move", kanbanMoveHandler);
1299
+ content.patch("/:slug/:id/kanban-position", kanbanPositionHandler);
1300
+ content.get("/:slug/view-config", getViewConfigHandler);
1301
+ content.put("/:slug/view-config", putViewConfigHandler);
1244
1302
  content.get("/:slug", listHandler);
1245
1303
  content.get("/:slug/facets", facetsHandler);
1246
1304
  content.get("/:schema_slug/by-slug/:entry_slug", getBySlugHandler);
@@ -1251,10 +1309,10 @@ content.put("/:slug/:id", updateHandler);
1251
1309
  content.delete("/:slug/:id", deleteHandler);
1252
1310
  var content_default = content;
1253
1311
 
1254
- // src/widget.ts
1255
- import { Hono as Hono3 } from "hono";
1312
+ // src/features/widget/widget.ts
1313
+ import { Hono as Hono2 } from "hono";
1256
1314
  import { deserializeFromDb } from "@beechcms/core";
1257
- var widgetApp = new Hono3();
1315
+ var widgetApp = new Hono2();
1258
1316
  var DEFAULT_LEADERBOARD_LIMIT = 10;
1259
1317
  var MAXIMUM_LEADERBOARD_LIMIT = 100;
1260
1318
  var DEFAULT_LIST_LIMIT = 25;
@@ -1445,8 +1503,8 @@ widgetApp.get("/distribution/:seed", async (context) => {
1445
1503
  });
1446
1504
 
1447
1505
  // 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";
1506
+ import { Hono as Hono3 } from "hono";
1507
+ import { resolvePolicies as resolvePolicies4, verifyHashField, sha256hex as sha256hex2, validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload4, EntryNotFoundError as EntryNotFoundError3 } from "@beechcms/core";
1450
1508
 
1451
1509
  // src/features/rotate-field/rotate-field.schema.ts
1452
1510
  import { z } from "zod";
@@ -1457,7 +1515,7 @@ var rotateFieldRequestSchema = z.object({
1457
1515
  });
1458
1516
 
1459
1517
  // src/features/rotate-field/rotate-field.handler.ts
1460
- var rotateFieldApp = new Hono4();
1518
+ var rotateFieldApp = new Hono3();
1461
1519
  rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
1462
1520
  const seedSlug = context.req.param("slug");
1463
1521
  const entryId = context.req.param("id");
@@ -1541,7 +1599,7 @@ rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
1541
1599
  detail: "Current value does not match stored value"
1542
1600
  });
1543
1601
  }
1544
- const fieldValidationResult = validateAndSanitizeSeedPayload3(
1602
+ const fieldValidationResult = validateAndSanitizeSeedPayload4(
1545
1603
  seed,
1546
1604
  { [fieldAlias]: nextValue },
1547
1605
  { operation: "update", allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: false }
@@ -1560,9 +1618,9 @@ rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
1560
1618
  });
1561
1619
 
1562
1620
  // src/features/password-reset/index.ts
1563
- import { Hono as Hono5 } from "hono";
1621
+ import { Hono as Hono4 } from "hono";
1564
1622
 
1565
- // src/features/email/providers/resend.ts
1623
+ // src/shared/email/providers/resend.ts
1566
1624
  var RESEND_API_URL = "https://api.resend.com/emails";
1567
1625
  var ResendEmailProvider = class {
1568
1626
  apiKey;
@@ -1598,7 +1656,7 @@ var ResendEmailProvider = class {
1598
1656
  }
1599
1657
  };
1600
1658
 
1601
- // src/features/email/providers/smtp.ts
1659
+ // src/shared/email/providers/smtp.ts
1602
1660
  function parseAddress(raw) {
1603
1661
  const match = raw.match(/^\s*(.+?)\s*<([^>]+)>\s*$/);
1604
1662
  if (match) return { Name: match[1], Email: match[2] };
@@ -1628,7 +1686,7 @@ var SmtpEmailProvider = class {
1628
1686
  }
1629
1687
  };
1630
1688
 
1631
- // src/features/email/templates/shell.ts
1689
+ // src/shared/email/templates/shell.ts
1632
1690
  function buildEmailShell(locale, slots) {
1633
1691
  const ctaBlock = slots.cta ? `<a href="${slots.cta.href}"
1634
1692
  style="display:inline-block;background:#111;color:#fff;padding:12px 24px;
@@ -1656,7 +1714,7 @@ function buildEmailShell(locale, slots) {
1656
1714
  </html>`;
1657
1715
  }
1658
1716
 
1659
- // src/features/email/templates/password-reset.ts
1717
+ // src/shared/email/templates/password-reset.ts
1660
1718
  var COPY = {
1661
1719
  en: {
1662
1720
  subject: "Reset your Beech CMS password",
@@ -1686,7 +1744,7 @@ function buildPasswordResetEmail(resetUrl, locale) {
1686
1744
  };
1687
1745
  }
1688
1746
 
1689
- // src/features/email/templates/password-changed.ts
1747
+ // src/shared/email/templates/password-changed.ts
1690
1748
  var COPY2 = {
1691
1749
  en: {
1692
1750
  subject: "Your Beech CMS password has been changed",
@@ -1716,7 +1774,7 @@ function buildPasswordChangedEmail(locale) {
1716
1774
  };
1717
1775
  }
1718
1776
 
1719
- // src/features/email/templates/automation-mail.ts
1777
+ // src/shared/email/templates/automation-mail.ts
1720
1778
  function buildAutomationEmail(params) {
1721
1779
  return {
1722
1780
  to: params.to,
@@ -1729,7 +1787,7 @@ function stripHtml(input) {
1729
1787
  return input.replace(/<[^>]+>/g, "").trim();
1730
1788
  }
1731
1789
 
1732
- // src/features/email/email.service.ts
1790
+ // src/shared/email/email.service.ts
1733
1791
  var DEFAULT_FROM = "Beech CMS <onboarding@resend.dev>";
1734
1792
  function createProvider(env) {
1735
1793
  if (env.provider === "smtp") {
@@ -1771,7 +1829,7 @@ async function sendPasswordChangedEmail(params) {
1771
1829
  async function sendAutomationMail(params) {
1772
1830
  const provider = createProvider({
1773
1831
  provider: params.provider,
1774
- apiKey: params.apiKey ?? params.resendApiKey,
1832
+ apiKey: params.apiKey ?? "",
1775
1833
  smtpBaseUrl: params.smtpBaseUrl,
1776
1834
  isDev: false
1777
1835
  });
@@ -1784,7 +1842,7 @@ async function sendAutomationMail(params) {
1784
1842
  });
1785
1843
  }
1786
1844
 
1787
- // src/features/email/email.types.ts
1845
+ // src/shared/email/email.types.ts
1788
1846
  var SUPPORTED_EMAIL_LOCALES = ["en", "it"];
1789
1847
  function resolveEmailLocale(raw) {
1790
1848
  if (typeof raw === "string" && SUPPORTED_EMAIL_LOCALES.includes(raw)) {
@@ -1817,7 +1875,11 @@ async function requestPasswordReset(context) {
1817
1875
  const clientIpAddress = getClientIp(req);
1818
1876
  const forgotPasswordRateLimit = await context.get("rateLimiters").getLimiter("forgotPassword").checkLimit(clientIpAddress);
1819
1877
  if (!forgotPasswordRateLimit.isAllowed) {
1820
- return context.json({ error: "Too many requests" }, 429);
1878
+ const headers = {};
1879
+ if (forgotPasswordRateLimit.retryAfterSeconds !== void 0) {
1880
+ headers["Retry-After"] = String(forgotPasswordRateLimit.retryAfterSeconds);
1881
+ }
1882
+ return context.json({ error: "Too many requests" }, 429, headers);
1821
1883
  }
1822
1884
  const registeredUser = await context.get("userRepository").findByEmail(normalizedEmail);
1823
1885
  if (!registeredUser) {
@@ -1859,6 +1921,7 @@ async function requestPasswordReset(context) {
1859
1921
  import { sha256hex as sha256hex4 } from "@beechcms/core";
1860
1922
  var MIN_PASSWORD_LENGTH2 = 8;
1861
1923
  var MAX_PASSWORD_LENGTH2 = 128;
1924
+ var MAX_PASSWORD_BYTES2 = 72;
1862
1925
  async function resetPassword(context) {
1863
1926
  const { env, req, executionCtx } = context;
1864
1927
  const useSmtp = env.EMAIL_PROVIDER === "smtp";
@@ -1868,7 +1931,11 @@ async function resetPassword(context) {
1868
1931
  const clientIpAddress = getClientIp(req);
1869
1932
  const resetPasswordRateLimit = await context.get("rateLimiters").getLimiter("resetPassword").checkLimit(clientIpAddress);
1870
1933
  if (!resetPasswordRateLimit.isAllowed) {
1871
- return context.json({ error: "Too many requests" }, 429);
1934
+ const headers = {};
1935
+ if (resetPasswordRateLimit.retryAfterSeconds !== void 0) {
1936
+ headers["Retry-After"] = String(resetPasswordRateLimit.retryAfterSeconds);
1937
+ }
1938
+ return context.json({ error: "Too many requests" }, 429, headers);
1872
1939
  }
1873
1940
  let payload;
1874
1941
  try {
@@ -1887,6 +1954,11 @@ async function resetPassword(context) {
1887
1954
  error: `Password must be between ${MIN_PASSWORD_LENGTH2} and ${MAX_PASSWORD_LENGTH2} characters`
1888
1955
  }, 400);
1889
1956
  }
1957
+ if (new TextEncoder().encode(newPassword).length > MAX_PASSWORD_BYTES2) {
1958
+ return context.json({
1959
+ error: `Password must not exceed ${MAX_PASSWORD_BYTES2} bytes when UTF-8 encoded`
1960
+ }, 400);
1961
+ }
1890
1962
  const emailLocale = resolveEmailLocale(payload.locale);
1891
1963
  const tokenHash = await sha256hex4(resetToken);
1892
1964
  const nowTimestamp = Math.floor(Date.now() / 1e3);
@@ -1925,7 +1997,7 @@ async function resetPassword(context) {
1925
1997
  }
1926
1998
 
1927
1999
  // src/features/password-reset/index.ts
1928
- var passwordResetApp = new Hono5();
2000
+ var passwordResetApp = new Hono4();
1929
2001
  passwordResetApp.get("/auth/features", (context) => {
1930
2002
  return context.json({ passwordReset: context.env.EMAIL_PROVIDER === "smtp" || Boolean(context.env.RESEND_API_KEY) });
1931
2003
  });
@@ -1933,8 +2005,8 @@ passwordResetApp.post("/auth/forgot-password", requestPasswordReset);
1933
2005
  passwordResetApp.post("/auth/reset-password", resetPassword);
1934
2006
 
1935
2007
  // src/features/setup/index.ts
1936
- import { Hono as Hono6 } from "hono";
1937
- var setupApp = new Hono6();
2008
+ import { Hono as Hono5 } from "hono";
2009
+ var setupApp = new Hono5();
1938
2010
  setupApp.get("/auth/setup", async (context) => {
1939
2011
  const userCount = await context.get("userRepository").countAll();
1940
2012
  const needsSetup = userCount === 0;
@@ -1996,6 +2068,14 @@ setupApp.post("/auth/setup", async (context) => {
1996
2068
  detail: "Password must be between 8 and 128 characters long."
1997
2069
  });
1998
2070
  }
2071
+ if (new TextEncoder().encode(password).length > 72) {
2072
+ return publicProblem(context, {
2073
+ type: "validation-error",
2074
+ title: "Invalid password",
2075
+ status: 422,
2076
+ detail: "Password must not exceed 72 bytes when UTF-8 encoded."
2077
+ });
2078
+ }
1999
2079
  if (!settings || typeof settings !== "object") {
2000
2080
  return publicProblem(context, {
2001
2081
  type: "validation-error",
@@ -2185,9 +2265,9 @@ setupApp.post("/auth/setup", async (context) => {
2185
2265
  });
2186
2266
 
2187
2267
  // src/features/draft/draft.handler.ts
2188
- import { Hono as Hono7 } from "hono";
2268
+ import { Hono as Hono6 } from "hono";
2189
2269
  import {
2190
- validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload4,
2270
+ validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload5,
2191
2271
  resolvePolicies as resolvePolicies5,
2192
2272
  RelationTargetNotFoundError
2193
2273
  } from "@beechcms/core";
@@ -2241,7 +2321,7 @@ var draftGuard = createMiddleware(async (context, next) => {
2241
2321
  });
2242
2322
 
2243
2323
  // src/features/draft/draft.handler.ts
2244
- var draftApp = new Hono7();
2324
+ var draftApp = new Hono6();
2245
2325
  draftApp.get("/drafts", async (context) => {
2246
2326
  const seeds = context.get("seedRegistry").draftEnabled();
2247
2327
  const repository = context.get("repository");
@@ -2293,7 +2373,7 @@ draftApp.put("/:slug/:id/draft", draftGuard, async (context) => {
2293
2373
  detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(", ")}`
2294
2374
  });
2295
2375
  }
2296
- const validation = validateAndSanitizeSeedPayload4(seed, body, {
2376
+ const validation = validateAndSanitizeSeedPayload5(seed, body, {
2297
2377
  operation: "update",
2298
2378
  allowNull: true,
2299
2379
  requireAtLeastOneValidField: true,
@@ -2381,12 +2461,13 @@ draftApp.delete("/:slug/:id/draft", draftGuard, async (context) => {
2381
2461
  });
2382
2462
 
2383
2463
  // src/features/settings/settings.handler.ts
2384
- import { Hono as Hono8 } from "hono";
2464
+ import { Hono as Hono7 } from "hono";
2385
2465
  import { sha256hex as sha256hex5 } from "@beechcms/core";
2386
- var settingsApp = new Hono8();
2466
+ var settingsApp = new Hono7();
2387
2467
  var EMAIL_VALIDATION_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2388
2468
  var MIN_PASSWORD_LENGTH3 = 8;
2389
2469
  var MAX_PASSWORD_LENGTH3 = 128;
2470
+ var MAX_PASSWORD_BYTES3 = 72;
2390
2471
  var SESSION_LIST_LIMIT = 20;
2391
2472
  var ACTIVITY_LOG_LIMIT = 30;
2392
2473
  settingsApp.get("/", async (context) => {
@@ -2549,6 +2630,9 @@ settingsApp.put("/password", async (context) => {
2549
2630
  if (newPassword.length > MAX_PASSWORD_LENGTH3) {
2550
2631
  return context.json({ error: "Password is too long" }, 400);
2551
2632
  }
2633
+ if (new TextEncoder().encode(newPassword).length > MAX_PASSWORD_BYTES3) {
2634
+ return context.json({ error: `Password must not exceed ${MAX_PASSWORD_BYTES3} bytes when UTF-8 encoded` }, 400);
2635
+ }
2552
2636
  const userRecord = await context.get("userRepository").findById(userId);
2553
2637
  if (!userRecord) {
2554
2638
  return context.json({ error: "User not found" }, 404);
@@ -2659,7 +2743,7 @@ settingsApp.put("/notifications", async (context) => {
2659
2743
  });
2660
2744
 
2661
2745
  // src/features/schema/schema.handler.ts
2662
- import { Hono as Hono9 } from "hono";
2746
+ import { Hono as Hono8 } from "hono";
2663
2747
  import { canEditLayout, formLayoutSchema, validateLayoutAgainstSeed } from "@beechcms/core";
2664
2748
  function requireLayoutEditPermission(context) {
2665
2749
  const role = context.get("jwtPayload")?.role;
@@ -2688,7 +2772,7 @@ function requireSeedBySlug(context, slug) {
2688
2772
  }
2689
2773
  return { seed, error: null };
2690
2774
  }
2691
- var schemaApp = new Hono9();
2775
+ var schemaApp = new Hono8();
2692
2776
  schemaApp.get("/", async (context) => {
2693
2777
  const registry = context.get("seedRegistry");
2694
2778
  const layouts = await context.get("seedLayoutRepository").getAllAsMap();
@@ -2750,7 +2834,7 @@ schemaApp.delete("/:slug/layout", async (context) => {
2750
2834
  });
2751
2835
 
2752
2836
  // src/features/dashboard-layout/dashboard-layout.handler.ts
2753
- import { Hono as Hono10 } from "hono";
2837
+ import { Hono as Hono9 } from "hono";
2754
2838
  import {
2755
2839
  canEditDashboard,
2756
2840
  dashboardLayoutSchema,
@@ -2810,7 +2894,7 @@ async function putLayout(context, scope) {
2810
2894
  await context.get("dashboardLayoutRepository").upsert(scope, result.cleaned, userId);
2811
2895
  return context.json({ ok: true, layout: result.cleaned, warnings: result.warnings });
2812
2896
  }
2813
- var dashboardLayoutApp = new Hono10();
2897
+ var dashboardLayoutApp = new Hono9();
2814
2898
  dashboardLayoutApp.get("/", async (context) => {
2815
2899
  const role = context.get("jwtPayload")?.role;
2816
2900
  const chain = resolveDashboardScopeChain(role);
@@ -2885,7 +2969,7 @@ dashboardLayoutApp.delete("/:scope", async (context) => {
2885
2969
  });
2886
2970
 
2887
2971
  // src/features/seeds/seeds.handler.ts
2888
- import { Hono as Hono11 } from "hono";
2972
+ import { Hono as Hono10 } from "hono";
2889
2973
  import {
2890
2974
  nextBranchId,
2891
2975
  validateSeedDefinitions,
@@ -3028,16 +3112,17 @@ async function deleteSeedMediaObjects(context, slug, seed, schemaMutator) {
3028
3112
  try {
3029
3113
  const dbCols = await schemaMutator.getColumns(`content_${slug}`);
3030
3114
  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 ?? [];
3115
+ const validCols = fileBranches.map((b) => b.alias).filter((a) => dbCols.has(a));
3116
+ if (validCols.length === 0) return;
3117
+ const sql = `SELECT ${validCols.join(", ")} FROM content_${slug}`;
3118
+ const { results: rows } = await context.env.DB.prepare(sql).all();
3119
+ const cdnUrl = context.env.MEDIA_CDN_URL;
3035
3120
  const r2Keys = [];
3036
3121
  for (const row of rows) {
3037
3122
  for (const b of fileBranches) {
3038
3123
  const val = row[b.alias];
3039
3124
  if (!val) continue;
3040
- const keys = extractMediaKeysFromData(seed, { [b.alias]: val });
3125
+ const keys = extractMediaKeysFromData(seed, { [b.alias]: val }, cdnUrl);
3041
3126
  r2Keys.push(...keys);
3042
3127
  }
3043
3128
  }
@@ -3048,7 +3133,7 @@ async function deleteSeedMediaObjects(context, slug, seed, schemaMutator) {
3048
3133
  } catch {
3049
3134
  }
3050
3135
  }
3051
- var seedsApp = new Hono11();
3136
+ var seedsApp = new Hono10();
3052
3137
  seedsApp.use("*", async (context, next) => {
3053
3138
  const denied = requireAdmin(context);
3054
3139
  if (denied) return denied;
@@ -3323,8 +3408,8 @@ seedsApp.patch("/:slug/branches/:branchId/retype", async (context) => {
3323
3408
  });
3324
3409
 
3325
3410
  // src/features/notifications/notifications.handler.ts
3326
- import { Hono as Hono12 } from "hono";
3327
- var notificationsApp = new Hono12();
3411
+ import { Hono as Hono11 } from "hono";
3412
+ var notificationsApp = new Hono11();
3328
3413
  var NOTIFICATION_LIST_LIMIT = 50;
3329
3414
  notificationsApp.get("/notifications", async (context) => {
3330
3415
  try {
@@ -3384,7 +3469,7 @@ notificationsApp.post("/notifications/mark-all-read", async (context) => {
3384
3469
  }
3385
3470
  });
3386
3471
 
3387
- // src/features/automations/template-grammar.ts
3472
+ // src/features/automations/evaluator/template-grammar.ts
3388
3473
  import { AUTOMATION_RESERVED_WORDS } from "@beechcms/core";
3389
3474
  var SLUG_RE2 = /^[a-zA-Z0-9_-]+$/;
3390
3475
  var AGGREGATE_OPS = /* @__PURE__ */ new Set(["count", "sum", "avg", "min", "max", "pluck"]);
@@ -3517,7 +3602,7 @@ function parseTemplateKey(raw) {
3517
3602
  return { kind: "var_access", name: nameToken, steps, conditions };
3518
3603
  }
3519
3604
 
3520
- // src/features/automations/automation-runner.utils.ts
3605
+ // src/features/automations/engine/automation-runner.utils.ts
3521
3606
  function interpolate(template, context, defaultValue = "", onMissing) {
3522
3607
  if (!template) return "";
3523
3608
  const replacer = (_, key) => {
@@ -3551,7 +3636,7 @@ function resolvePath(obj, path) {
3551
3636
  return current;
3552
3637
  }
3553
3638
 
3554
- // src/features/automations/context-resolver.ts
3639
+ // src/features/automations/evaluator/context-resolver.ts
3555
3640
  var MAX_PLUCK = 100;
3556
3641
  function applyAggregate(op, field, rows, onMissing) {
3557
3642
  switch (op) {
@@ -3666,27 +3751,16 @@ async function resolveAutomationContext(_automation, triggerEntry, batchEntries)
3666
3751
  return { lookup, triggerEntry };
3667
3752
  }
3668
3753
 
3669
- // src/features/automations/action-executors/webhook.executor.ts
3754
+ // src/features/automations/executors/webhook.executor.ts
3755
+ import { signWebhookBody } from "@beechcms/core/webhook-crypto";
3670
3756
  var WEBHOOK_TIMEOUT_MS = 8e3;
3671
3757
  var warnedNoSecret = false;
3672
- async function signBody(body, secret) {
3673
- const encoder = new TextEncoder();
3674
- const key = await crypto.subtle.importKey(
3675
- "raw",
3676
- encoder.encode(secret),
3677
- { name: "HMAC", hash: "SHA-256" },
3678
- false,
3679
- ["sign"]
3680
- );
3681
- const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
3682
- return "sha256=" + Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
3683
- }
3684
3758
  async function executeWebhook(action, context, env) {
3685
3759
  const body = interpolate(action.body_template ?? "{}", context);
3686
3760
  const secret = env.WEBHOOK_SECRET;
3687
3761
  let signatureHeader = {};
3688
3762
  if (secret) {
3689
- signatureHeader = { "X-BeechCMS-Signature": await signBody(body, secret) };
3763
+ signatureHeader = { "X-BeechCMS-Signature": await signWebhookBody(body, secret) };
3690
3764
  } else if (!warnedNoSecret) {
3691
3765
  warnedNoSecret = true;
3692
3766
  console.warn("[webhook] WEBHOOK_SECRET not set \u2014 outgoing webhooks are unsigned");
@@ -3713,7 +3787,7 @@ async function executeWebhook(action, context, env) {
3713
3787
  }
3714
3788
  }
3715
3789
 
3716
- // src/features/automations/action-executors/send-mail.executor.ts
3790
+ // src/features/automations/executors/send-mail.executor.ts
3717
3791
  async function executeSendMail(action, context, env) {
3718
3792
  const missingFields = [];
3719
3793
  const logMissing = (field) => missingFields.push(field);
@@ -3746,7 +3820,7 @@ async function executeSendMail(action, context, env) {
3746
3820
  }
3747
3821
  }
3748
3822
 
3749
- // src/features/automations/action-executors/edit-field.executor.ts
3823
+ // src/features/automations/executors/edit-field.executor.ts
3750
3824
  async function executeEditField(action, entry, context, repository, seed) {
3751
3825
  const id = entry.id;
3752
3826
  if (typeof id !== "string") {
@@ -3756,7 +3830,7 @@ async function executeEditField(action, entry, context, repository, seed) {
3756
3830
  await repository.update(seed, id, { [action.field]: resolved });
3757
3831
  }
3758
3832
 
3759
- // src/features/automations/action-executors/create-entry.executor.ts
3833
+ // src/features/automations/executors/create-entry.executor.ts
3760
3834
  async function executeCreateEntry(action, entry, repository, getSeed, idGenerator) {
3761
3835
  const targetSeed = getSeed(action.seed_slug);
3762
3836
  if (!targetSeed) throw new Error(`create_entry: unknown seed ${action.seed_slug}`);
@@ -3768,7 +3842,7 @@ async function executeCreateEntry(action, entry, repository, getSeed, idGenerato
3768
3842
  await repository.create(targetSeed, newId, newId, "draft", payload);
3769
3843
  }
3770
3844
 
3771
- // src/features/automations/filter-translation.ts
3845
+ // src/features/automations/filters/filter-translation.ts
3772
3846
  var SYSTEM_COLUMNS = /* @__PURE__ */ new Set(["id", "slug", "status", "created_at", "updated_at"]);
3773
3847
  function mapBranchTypeToFilterType(type) {
3774
3848
  switch (type) {
@@ -3806,7 +3880,7 @@ function conditionToFilterGroup(c, seed) {
3806
3880
  };
3807
3881
  }
3808
3882
 
3809
- // src/features/automations/action-executors/set-variable.executor.ts
3883
+ // src/features/automations/executors/set-variable.executor.ts
3810
3884
  async function executeSetVariable(action, ctx) {
3811
3885
  const seedSlug = action.seed_slug ?? ctx.seed.slug;
3812
3886
  const targetSeed = ctx.getSeed(seedSlug);
@@ -3919,7 +3993,7 @@ function calculateGeneralStats(seed, items) {
3919
3993
  return { count: items.length, sum, avg, min, max, pluck };
3920
3994
  }
3921
3995
 
3922
- // src/features/automations/action-executors/index.ts
3996
+ // src/features/automations/executors/index.ts
3923
3997
  async function executeAction(action, ctx) {
3924
3998
  switch (action.type) {
3925
3999
  case "set_variable":
@@ -3939,7 +4013,7 @@ async function executeAction(action, ctx) {
3939
4013
  }
3940
4014
  }
3941
4015
 
3942
- // src/features/automations/when-evaluator.ts
4016
+ // src/features/automations/filters/when-evaluator.ts
3943
4017
  function evaluateWhen(node, context) {
3944
4018
  if (!node) return true;
3945
4019
  if (node.kind === "predicate") return evalPredicate(node, context);
@@ -4019,7 +4093,7 @@ function coerceEqual(a, b) {
4019
4093
  return a === b;
4020
4094
  }
4021
4095
 
4022
- // src/features/automations/var-access-resolver.ts
4096
+ // src/features/automations/evaluator/var-access-resolver.ts
4023
4097
  function toNumeric(v) {
4024
4098
  const n = Number(v);
4025
4099
  if (Number.isFinite(n)) return n;
@@ -4122,7 +4196,7 @@ function resolveVarAccess(parsed, variables, onMissing) {
4122
4196
  return current;
4123
4197
  }
4124
4198
 
4125
- // src/features/automations/automation-runner.ts
4199
+ // src/features/automations/engine/automation-runner.ts
4126
4200
  function withVariables(base, variables) {
4127
4201
  return {
4128
4202
  triggerEntry: base.triggerEntry,
@@ -4175,10 +4249,10 @@ var AutomationRunner = class {
4175
4249
  }
4176
4250
  };
4177
4251
 
4178
- // src/features/automations/automations.handler.ts
4179
- import { Hono as Hono13 } from "hono";
4252
+ // src/features/automations/api/automations.handler.ts
4253
+ import { Hono as Hono12 } from "hono";
4180
4254
 
4181
- // src/features/automations/automations.schema.ts
4255
+ // src/features/automations/api/automations.schema.ts
4182
4256
  import { z as z2 } from "zod";
4183
4257
  import { isPrivateHost } from "@beechcms/core";
4184
4258
  var whenOperandSchema = z2.union([
@@ -4309,8 +4383,8 @@ var toggleAutomationSchema = z2.object({
4309
4383
  enabled: z2.boolean()
4310
4384
  });
4311
4385
 
4312
- // src/features/automations/automations.handler.ts
4313
- var automationsApp = new Hono13();
4386
+ // src/features/automations/api/automations.handler.ts
4387
+ var automationsApp = new Hono12();
4314
4388
  automationsApp.get("/", async (context) => {
4315
4389
  const seedSlug = context.req.query("seed");
4316
4390
  if (!seedSlug) {
@@ -4456,7 +4530,7 @@ automationsApp.delete("/:id", async (context) => {
4456
4530
  });
4457
4531
 
4458
4532
  // src/features/stats/stats.handler.ts
4459
- import { Hono as Hono14 } from "hono";
4533
+ import { Hono as Hono13 } from "hono";
4460
4534
  import { SystemClock } from "@beechcms/core";
4461
4535
  var DATABASE_ERROR = "Database error";
4462
4536
  var INTERNAL_SERVER_ERROR = "Internal Server Error";
@@ -4478,7 +4552,7 @@ function getMimeType(extension) {
4478
4552
  const type = extension === "jpg" ? "jpeg" : extension || "jpeg";
4479
4553
  return `image/${type}`;
4480
4554
  }
4481
- var statsApp = new Hono14();
4555
+ var statsApp = new Hono13();
4482
4556
  statsApp.get("/stats/media-library", async (context) => {
4483
4557
  try {
4484
4558
  const mediaRepository = context.get("mediaRepository");
@@ -4774,7 +4848,7 @@ statsApp.post("/stats/storage/sync", async (context) => {
4774
4848
  });
4775
4849
 
4776
4850
  // src/features/backrefs/backrefs.handler.ts
4777
- import { Hono as Hono15 } from "hono";
4851
+ import { Hono as Hono14 } from "hono";
4778
4852
  import { resolvePolicies as resolvePolicies6 } from "@beechcms/core";
4779
4853
 
4780
4854
  // src/features/backrefs/d1-backref.repository.ts
@@ -4826,7 +4900,7 @@ var D1BackrefRepository = class {
4826
4900
  var PREVIEW_LIMIT = 3;
4827
4901
  var DEFAULT_PAGE_LIMIT = 20;
4828
4902
  var MAX_PAGE_LIMIT = 100;
4829
- var backrefsApp = new Hono15();
4903
+ var backrefsApp = new Hono14();
4830
4904
  backrefsApp.get("/:targetSlug/:targetId/backrefs", async (c) => {
4831
4905
  const targetSlug = c.req.param("targetSlug");
4832
4906
  const targetId = c.req.param("targetId");
@@ -4946,9 +5020,9 @@ function filterVisibility(items, sourceSeed) {
4946
5020
  }
4947
5021
 
4948
5022
  // src/features/webhooks/index.ts
4949
- import { Hono as Hono16 } from "hono";
5023
+ import { Hono as Hono15 } from "hono";
4950
5024
  import { Receiver } from "@upstash/qstash";
4951
- var webhooksApp = new Hono16();
5025
+ var webhooksApp = new Hono15();
4952
5026
  webhooksApp.post("/qstash", async (context) => {
4953
5027
  const currentSigningKey = context.env.QSTASH_CURRENT_SIGNING_KEY;
4954
5028
  const nextSigningKey = context.env.QSTASH_NEXT_SIGNING_KEY;
@@ -4956,41 +5030,214 @@ webhooksApp.post("/qstash", async (context) => {
4956
5030
  console.error("QStash webhook called but signing keys are not configured");
4957
5031
  return context.text("Configuration error", 500);
4958
5032
  }
4959
- const receiver = new Receiver({
4960
- currentSigningKey,
4961
- nextSigningKey
4962
- });
4963
- const body = await context.req.text();
4964
- const signature = context.req.header("Upstash-Signature");
4965
- if (!signature) {
4966
- return context.text("Missing signature", 401);
5033
+ const receiver = new Receiver({
5034
+ currentSigningKey,
5035
+ nextSigningKey
5036
+ });
5037
+ const body = await context.req.text();
5038
+ const signature = context.req.header("Upstash-Signature");
5039
+ if (!signature) {
5040
+ return context.text("Missing signature", 401);
5041
+ }
5042
+ try {
5043
+ const isValid = await receiver.verify({
5044
+ signature,
5045
+ body
5046
+ });
5047
+ if (!isValid) {
5048
+ return context.text("Invalid signature", 401);
5049
+ }
5050
+ } catch (err) {
5051
+ console.error("QStash signature verification failed", err);
5052
+ return context.text("Invalid signature", 401);
5053
+ }
5054
+ try {
5055
+ const input = JSON.parse(body);
5056
+ const notificationRepository = context.get("notificationRepository");
5057
+ await notificationRepository.create({
5058
+ title: input.title,
5059
+ message: input.message,
5060
+ type: input.type ?? "info"
5061
+ });
5062
+ return context.text("OK", 200);
5063
+ } catch (error) {
5064
+ console.error("Failed to process QStash webhook payload", error);
5065
+ return context.text("Internal server error", 500);
5066
+ }
5067
+ });
5068
+
5069
+ // src/features/upload/index.ts
5070
+ import { Hono as Hono16 } from "hono";
5071
+ import { isMimeAccepted } from "@beechcms/core";
5072
+ var DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
5073
+ var ABSOLUTE_MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
5074
+ var PRESIGN_TTL_SECONDS = 900;
5075
+ function resolveMaxUploadBytes(env) {
5076
+ const raw = env.MAX_UPLOAD_BYTES;
5077
+ if (!raw) return DEFAULT_MAX_UPLOAD_BYTES;
5078
+ const parsed = Number.parseInt(raw, 10);
5079
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_UPLOAD_BYTES;
5080
+ return Math.min(parsed, ABSOLUTE_MAX_UPLOAD_BYTES);
5081
+ }
5082
+ function sanitizeFilename(name) {
5083
+ const base = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 100);
5084
+ return base || "file";
5085
+ }
5086
+ function generateObjectKey(originalName) {
5087
+ const timestamp = Math.floor(Date.now() / 1e3);
5088
+ return `${timestamp}-${sanitizeFilename(originalName)}`;
5089
+ }
5090
+ var FORCE_DOWNLOAD_MIME_PREFIXES = [
5091
+ "image/svg",
5092
+ "text/",
5093
+ "application/xml",
5094
+ "application/xhtml",
5095
+ "application/javascript",
5096
+ "application/x-javascript"
5097
+ ];
5098
+ function isActiveMimeType(mime) {
5099
+ const lower = mime.toLowerCase();
5100
+ return FORCE_DOWNLOAD_MIME_PREFIXES.some((prefix) => lower.startsWith(prefix));
5101
+ }
5102
+ var uploadRoutes = new Hono16();
5103
+ uploadRoutes.post("/upload/presign", async (c) => {
5104
+ let body;
5105
+ try {
5106
+ body = await c.req.json();
5107
+ } catch {
5108
+ return c.json({ error: "Invalid JSON body" }, 400);
5109
+ }
5110
+ const { filename, mimeType, sizeBytes } = body;
5111
+ if (typeof filename !== "string" || !filename.trim()) return c.json({ error: "filename is required" }, 400);
5112
+ if (typeof mimeType !== "string" || !mimeType.trim()) return c.json({ error: "mimeType is required" }, 400);
5113
+ if (typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || sizeBytes <= 0) {
5114
+ return c.json({ error: "sizeBytes must be a positive number" }, 400);
5115
+ }
5116
+ const maxBytes = resolveMaxUploadBytes(c.env);
5117
+ if (sizeBytes > maxBytes) return c.json({ error: `File too large. Max ${maxBytes} bytes` }, 400);
5118
+ if (!isMimeAccepted(mimeType, "any")) return c.json({ error: "File type not allowed" }, 400);
5119
+ const key = generateObjectKey(filename);
5120
+ const uploadUrl = await c.var.bucket.presignPut(key, {
5121
+ expiresIn: PRESIGN_TTL_SECONDS,
5122
+ contentType: mimeType,
5123
+ contentLength: sizeBytes
5124
+ });
5125
+ return c.json({ uploadUrl, key, expiresIn: PRESIGN_TTL_SECONDS }, 200);
5126
+ });
5127
+ uploadRoutes.post("/upload/confirm", async (c) => {
5128
+ const { bucket, mediaRepository: mediaRepo, systemStatsRepository: statsRepo } = c.var;
5129
+ let body;
5130
+ try {
5131
+ body = await c.req.json();
5132
+ } catch {
5133
+ return c.json({ error: "Invalid JSON body" }, 400);
5134
+ }
5135
+ const { key } = body;
5136
+ if (typeof key !== "string" || !key.trim()) return c.json({ error: "key is required" }, 400);
5137
+ const sanitizedKey = decodeURIComponent(key).replace(/\.\.[/\\]/g, "").replace(/[^a-zA-Z0-9._\-/]/g, "");
5138
+ if (!sanitizedKey || !/^\d+-[a-zA-Z0-9._-]+$/.test(sanitizedKey)) {
5139
+ return c.json({ error: "Invalid key format" }, 400);
5140
+ }
5141
+ const existing = await mediaRepo.getByKey(sanitizedKey);
5142
+ if (existing) return c.json({ url: bucket.getUrl(sanitizedKey) }, 200);
5143
+ const head = await bucket.head(sanitizedKey);
5144
+ if (!head) return c.json({ error: "Object not found in storage" }, 404);
5145
+ const size = head.size ?? 0;
5146
+ const maxBytes = resolveMaxUploadBytes(c.env);
5147
+ if (size > maxBytes) {
5148
+ return c.json({ error: `File too large. Max ${maxBytes} bytes` }, 400);
5149
+ }
5150
+ const uploadedBy = c.var.jwtPayload?.sub ?? "";
5151
+ const mime = head.contentType ?? "application/octet-stream";
5152
+ const filename = sanitizedKey.replace(/^\d+-/, "") || sanitizedKey;
5153
+ await statsRepo.incrementStorage(size);
5154
+ await mediaRepo.trackUpload({
5155
+ key: sanitizedKey,
5156
+ filename,
5157
+ mime_type: mime,
5158
+ size_bytes: size,
5159
+ uploaded_by: uploadedBy
5160
+ });
5161
+ const jwtPayload = c.get("jwtPayload");
5162
+ if (jwtPayload) {
5163
+ c.get("activityLogger").log({
5164
+ action: "upload",
5165
+ entityType: "media",
5166
+ entityId: sanitizedKey,
5167
+ details: { name: filename, size, type: mime },
5168
+ actor: {
5169
+ id: jwtPayload.sub,
5170
+ email: jwtPayload.email ?? "unknown",
5171
+ name: [jwtPayload.name, jwtPayload.surname].filter(Boolean).join(" ") || null
5172
+ }
5173
+ });
5174
+ }
5175
+ return c.json({ url: bucket.getUrl(sanitizedKey) }, 200);
5176
+ });
5177
+ uploadRoutes.get("/upload/download-url/:key", async (c) => {
5178
+ let key = c.req.param("key");
5179
+ if (!key) return c.json({ error: "Missing key" }, 400);
5180
+ key = decodeURIComponent(key);
5181
+ key = key.replace(/\.\.[/\\]/g, "");
5182
+ key = key.replace(/[^a-zA-Z0-9._\-/]/g, "");
5183
+ if (!key) return c.json({ error: "Invalid key" }, 400);
5184
+ const media = await c.var.mediaRepository.getByKey(key).catch(() => null);
5185
+ if (!media) {
5186
+ return c.json({ error: "Media not found" }, 404);
5187
+ }
5188
+ const jwtPayload = c.var.jwtPayload;
5189
+ const userId = jwtPayload?.sub;
5190
+ const isAdmin = jwtPayload?.role === "admin";
5191
+ if (!isAdmin && userId !== media.uploaded_by) {
5192
+ return c.json({ error: "Forbidden" }, 403);
4967
5193
  }
5194
+ const head = await c.var.bucket.head(key);
5195
+ if (!head) return c.json({ error: "Object not found" }, 404);
5196
+ const downloadUrl = await c.var.bucket.presignGet(key, { expiresIn: PRESIGN_TTL_SECONDS });
5197
+ return c.json({ downloadUrl, expiresIn: PRESIGN_TTL_SECONDS }, 200);
5198
+ });
5199
+ uploadRoutes.delete("/upload/:key", async (c) => {
5200
+ let key = c.req.param("key");
5201
+ if (!key) return c.json({ error: "Missing key" }, 400);
5202
+ key = key.replace(/\.\.[/\\]/g, "");
5203
+ key = key.replace(/[^a-zA-Z0-9._\-/]/g, "");
5204
+ if (!key) return c.json({ error: "Invalid key" }, 400);
5205
+ const media = await c.var.mediaRepository.getByKey(key).catch(() => null);
5206
+ if (!media) {
5207
+ return c.json({ error: "Media not found" }, 404);
5208
+ }
5209
+ const jwtPayload = c.var.jwtPayload;
5210
+ const userId = jwtPayload?.sub;
5211
+ const isAdmin = jwtPayload?.role === "admin";
5212
+ if (!isAdmin && userId !== media.uploaded_by) {
5213
+ return c.json({ error: "Forbidden" }, 403);
5214
+ }
5215
+ await deleteR2Objects(c, [key]);
5216
+ return c.json({ success: true }, 200);
5217
+ });
5218
+ async function serveMediaHandler(c) {
5219
+ const key = c.req.param("key");
5220
+ if (!key) return new Response("Missing key", { status: 400 });
4968
5221
  try {
4969
- const isValid = await receiver.verify({
4970
- signature,
4971
- body
4972
- });
4973
- if (!isValid) {
4974
- return context.text("Invalid signature", 401);
5222
+ const object = await c.var.bucket.get(key);
5223
+ if (!object) return new Response("Not found", { status: 404 });
5224
+ const headers = new Headers();
5225
+ const originalMime = object.contentType ?? "application/octet-stream";
5226
+ if (isActiveMimeType(originalMime)) {
5227
+ headers.set("Content-Type", "application/octet-stream");
5228
+ headers.set("Content-Disposition", "attachment");
5229
+ } else {
5230
+ headers.set("Content-Type", originalMime);
4975
5231
  }
5232
+ headers.set("Content-Security-Policy", "default-src 'none'; sandbox");
5233
+ headers.set("X-Content-Type-Options", "nosniff");
5234
+ headers.set("Cache-Control", "public, max-age=31536000, immutable");
5235
+ return new Response(object.body, { status: 200, headers });
4976
5236
  } catch (err) {
4977
- console.error("QStash signature verification failed", err);
4978
- return context.text("Invalid signature", 401);
4979
- }
4980
- try {
4981
- const input = JSON.parse(body);
4982
- const notificationRepository = context.get("notificationRepository");
4983
- await notificationRepository.create({
4984
- title: input.title,
4985
- message: input.message,
4986
- type: input.type ?? "info"
4987
- });
4988
- return context.text("OK", 200);
4989
- } catch (error) {
4990
- console.error("Failed to process QStash webhook payload", error);
4991
- return context.text("Internal server error", 500);
5237
+ console.error(`[serveMediaHandler] Error serving file ${key}:`, err);
5238
+ return new Response("Internal error", { status: 500 });
4992
5239
  }
4993
- });
5240
+ }
4994
5241
 
4995
5242
  // src/public/public-errors.ts
4996
5243
  var PUBLIC_ERRORS = {
@@ -5051,11 +5298,16 @@ function publicRateLimitMiddleware() {
5051
5298
  const key = `${getClientIp(c.req)}:${seed}:${limiterName}`;
5052
5299
  const result = await c.get("rateLimiters").getLimiter(limiterName).checkLimit(key);
5053
5300
  if (!result.isAllowed) {
5301
+ const headers = {};
5302
+ if (result.retryAfterSeconds !== void 0) {
5303
+ headers["Retry-After"] = String(result.retryAfterSeconds);
5304
+ }
5054
5305
  return publicProblem(c, {
5055
5306
  type: "rate-limit-exceeded",
5056
5307
  title: "Too Many Requests",
5057
5308
  status: 429,
5058
- detail: "Too many requests"
5309
+ detail: "Too many requests",
5310
+ headers
5059
5311
  });
5060
5312
  }
5061
5313
  await next();
@@ -5374,10 +5626,10 @@ import { isValidContentStatus as isValidContentStatus3, SlugConflictError as Slu
5374
5626
  import { slugify as slugify3, generateEntrySlug } from "@beechcms/core";
5375
5627
 
5376
5628
  // src/public/sanitize.ts
5377
- import { validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload5 } from "@beechcms/core";
5629
+ import { validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload6 } from "@beechcms/core";
5378
5630
  function sanitizePublicPayload(seed, payload, options = {}) {
5379
5631
  const operation = options.operation ?? "create";
5380
- const result = validateAndSanitizeSeedPayload5(seed, payload, {
5632
+ const result = validateAndSanitizeSeedPayload6(seed, payload, {
5381
5633
  allowNull: options.allowNull ?? false,
5382
5634
  operation,
5383
5635
  requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
@@ -5689,10 +5941,10 @@ publicApp.post("/:seed/add", publicAddHandler);
5689
5941
  publicApp.put("/:seed/edit/:id", publicEditHandler);
5690
5942
  var publicRoutes = publicApp;
5691
5943
 
5692
- // src/search.ts
5944
+ // src/features/search/search.ts
5693
5945
  import { Hono as Hono18 } from "hono";
5694
5946
 
5695
- // src/search-utils.ts
5947
+ // src/features/search/search-utils.ts
5696
5948
  function encodeCursor(rank, entryId) {
5697
5949
  return btoa(`${rank}:${entryId}`);
5698
5950
  }
@@ -5797,7 +6049,7 @@ function mapSearchResultRow(row) {
5797
6049
  };
5798
6050
  }
5799
6051
 
5800
- // src/search.ts
6052
+ // src/features/search/search.ts
5801
6053
  var searchRouter = new Hono18();
5802
6054
  searchRouter.use("*", authMiddleware());
5803
6055
  searchRouter.get("/", async (c) => {
@@ -5838,19 +6090,19 @@ searchRouter.get("/", async (c) => {
5838
6090
  // src/middleware/repository.middleware.ts
5839
6091
  import { createMiddleware as createMiddleware2 } from "hono/factory";
5840
6092
 
5841
- // src/shared/content.repository.d1.ts
6093
+ // src/shared/db/repositories/content.repository.d1.ts
5842
6094
  import {
5843
6095
  EntryNotFoundError as EntryNotFoundError7,
5844
6096
  RepositoryError as RepositoryError2,
5845
- SlugConflictError as SlugConflictError3,
6097
+ SlugConflictError as SlugConflictError4,
5846
6098
  RelationTargetNotFoundError as RelationTargetNotFoundError2,
5847
6099
  buildSelectQuery,
5848
6100
  deserializeFromDb as deserializeFromDb2,
5849
6101
  serializeForDb
5850
6102
  } from "@beechcms/core";
5851
6103
 
5852
- // src/shared/base.repository.d1.ts
5853
- import { RepositoryError } from "@beechcms/core";
6104
+ // src/shared/db/repositories/base.repository.d1.ts
6105
+ import { RepositoryError, SlugConflictError as SlugConflictError3 } from "@beechcms/core";
5854
6106
  var BaseD1Repository = class {
5855
6107
  constructor(database) {
5856
6108
  this.database = database;
@@ -5868,11 +6120,14 @@ var BaseD1Repository = class {
5868
6120
  */
5869
6121
  mapError(error, context) {
5870
6122
  const message = error?.message || "Unknown database error";
6123
+ if (message.includes("UNIQUE constraint failed:") && message.includes(".slug")) {
6124
+ return new SlugConflictError3(`${context}: ${message}`);
6125
+ }
5871
6126
  return new RepositoryError(`${context}: ${message}`, error);
5872
6127
  }
5873
6128
  };
5874
6129
 
5875
- // src/shared/content.repository.d1.ts
6130
+ // src/shared/db/repositories/content.repository.d1.ts
5876
6131
  function multiRelBranches(seed) {
5877
6132
  return seed.branches.filter((b) => b.type === "relation" && b.multiple === true);
5878
6133
  }
@@ -5891,6 +6146,7 @@ var D1ContentRepository = class extends BaseD1Repository {
5891
6146
  this.hooks = hooks;
5892
6147
  }
5893
6148
  hooks;
6149
+ /** Assembles the {@link HookContext} passed to every lifecycle hook invocation. */
5894
6150
  hookCtx(seed, actor) {
5895
6151
  return { seed, repository: this, actor, db: this.database };
5896
6152
  }
@@ -5912,10 +6168,15 @@ var D1ContentRepository = class extends BaseD1Repository {
5912
6168
  data[branch.alias] = deserializeFromDb2(branch, row[branch.alias]);
5913
6169
  }
5914
6170
  }
6171
+ if (row.position !== void 0) data.position = row.position;
5915
6172
  return data;
5916
6173
  }
5917
6174
  /**
5918
6175
  * Fetches multi-relation arrays for a single entry and attaches them to `data`.
6176
+ *
6177
+ * @param seed The seed schema definition.
6178
+ * @param entryId Id of the entry to fetch relation targets for.
6179
+ * @param data Mutated in place: one array property per multi-relation branch alias.
5919
6180
  */
5920
6181
  async attachMultiRelations(seed, entryId, data) {
5921
6182
  const branches = multiRelBranches(seed);
@@ -5929,51 +6190,85 @@ var D1ContentRepository = class extends BaseD1Repository {
5929
6190
  }
5930
6191
  }
5931
6192
  /**
5932
- * Fetches multi-relation arrays for a list of entries and attaches them.
5933
- * One query per multi-relation branch (O(R) queries, R = branch count).
6193
+ * Builds one junction-table statement per multi-relation branch, selecting the rows whose
6194
+ * `parent_id` falls within the page defined by `idQuery` (the page selection re-projected to
6195
+ * ids only). Using a subquery instead of a bound `IN (?, ...)` id list keeps the statements
6196
+ * eligible for the same batch as the page query and avoids D1's bound-parameter limit.
6197
+ *
6198
+ * The subquery is wrapped in `SELECT id FROM (...)` because `kanbanOrder` projections carry an
6199
+ * extra `kp.position` column, and `IN` requires a single-column subselect.
5934
6200
  */
5935
- async attachMultiRelationsMany(seed, entries) {
5936
- const branches = multiRelBranches(seed);
5937
- if (branches.length === 0 || entries.length === 0) return;
5938
- const ids = entries.map((e) => e.id);
5939
- const placeholders = ids.map(() => "?").join(", ");
5940
- const stmts = branches.map(
6201
+ multiRelStatements(seed, branches, idQuery) {
6202
+ return branches.map(
5941
6203
  (b) => this.database.prepare(
5942
- `SELECT parent_id, target_id FROM ${jTable(seed.slug, b.alias)} WHERE parent_id IN (${placeholders}) ORDER BY parent_id, position ASC`
5943
- ).bind(...ids)
6204
+ `SELECT parent_id, target_id FROM ${jTable(seed.slug, b.alias)} WHERE parent_id IN (SELECT id FROM (${idQuery.sql})) ORDER BY position ASC`
6205
+ ).bind(...idQuery.bindings)
5944
6206
  );
5945
- const results = await this.database.batch(stmts);
6207
+ }
6208
+ /**
6209
+ * Groups junction rows by parent and attaches them to the entries: one array property per
6210
+ * multi-relation branch alias, per entry. `relResults[i]` must correspond to `branches[i]`.
6211
+ *
6212
+ * @param entries Mutated in place.
6213
+ */
6214
+ attachMultiRelationRows(branches, relResults, entries) {
5946
6215
  for (let i = 0; i < branches.length; i++) {
5947
6216
  const branch = branches[i];
5948
6217
  const byParent = /* @__PURE__ */ new Map();
5949
- for (const row of results[i].results ?? []) {
6218
+ for (const row of relResults[i].results ?? []) {
5950
6219
  const r = row;
5951
6220
  const pid = r.parent_id;
5952
- if (!byParent.has(pid)) byParent.set(pid, []);
5953
- byParent.get(pid).push(r.target_id);
6221
+ let targets = byParent.get(pid);
6222
+ if (!targets) {
6223
+ targets = [];
6224
+ byParent.set(pid, targets);
6225
+ }
6226
+ targets.push(r.target_id);
5954
6227
  }
5955
6228
  for (const entry of entries) {
5956
6229
  entry[branch.alias] = byParent.get(entry.id) ?? [];
5957
6230
  }
5958
6231
  }
5959
6232
  }
6233
+ /**
6234
+ * Finds multiple entries for a given seed table based on the provided query options.
6235
+ * Executes a single batch (one D1 round-trip) containing:
6236
+ * 1. A selection query to retrieve the items for the current page (with columns, limits, offsets, and ordering).
6237
+ * 2. A count query (using `isCount: true`) to retrieve the total number of matched items matching the same filters.
6238
+ * 3. One junction-table query per multi-relation branch, scoped to the page via an id subquery.
6239
+ *
6240
+ * @param seed The seed schema definition.
6241
+ * @param options Filtering, search, ordering, and pagination options.
6242
+ * @returns An object containing the list of serialized entries and the total count of matched items.
6243
+ * @throws RepositoryError if the database batch execution fails.
6244
+ */
5960
6245
  async findMany(seed, options) {
5961
6246
  try {
5962
6247
  const { sql, bindings } = buildSelectQuery(seed, options);
5963
- const countSql = sql.replace(/SELECT .* FROM/, "SELECT COUNT(*) as total FROM").replace(/ ORDER BY .*$/, "").replace(/ LIMIT \? OFFSET \?$/, "");
5964
- const countBindings = bindings.slice(0, bindings.length - (options.pagination ? 2 : 0));
5965
- const [batchResults, totalCountResult] = await this.database.batch([
6248
+ const { sql: countSql, bindings: countBindings } = buildSelectQuery(seed, {
6249
+ ...options,
6250
+ isCount: true
6251
+ });
6252
+ const branches = multiRelBranches(seed);
6253
+ const relStmts = branches.length > 0 ? this.multiRelStatements(seed, branches, buildSelectQuery(seed, { ...options, fields: ["id"] })) : [];
6254
+ const [batchResults, totalCountResult, ...relResults] = await this.database.batch([
5966
6255
  this.database.prepare(sql).bind(...bindings),
5967
- this.database.prepare(countSql).bind(...countBindings)
6256
+ this.database.prepare(countSql).bind(...countBindings),
6257
+ ...relStmts
5968
6258
  ]);
5969
6259
  const contentEntries = (batchResults.results || []).map((row) => this.rowToData(seed, row));
5970
6260
  const totalEntriesCount = totalCountResult.results?.[0]?.total || 0;
5971
- await this.attachMultiRelationsMany(seed, contentEntries);
6261
+ this.attachMultiRelationRows(branches, relResults, contentEntries);
5972
6262
  return { items: contentEntries, total: totalEntriesCount };
5973
6263
  } catch (error) {
5974
6264
  throw this.mapError(error, `findMany(${seed.slug})`);
5975
6265
  }
5976
6266
  }
6267
+ /**
6268
+ * Fetches a single entry by id, including its multi-relation arrays.
6269
+ * @throws EntryNotFoundError if no row with `id` exists in the seed's table.
6270
+ * @throws RepositoryError on any other database failure.
6271
+ */
5977
6272
  async findById(seed, id) {
5978
6273
  try {
5979
6274
  const tableName = this.getTableName(seed.slug);
@@ -5989,6 +6284,11 @@ var D1ContentRepository = class extends BaseD1Repository {
5989
6284
  throw this.mapError(error, `findById(${seed.slug}, ${id})`);
5990
6285
  }
5991
6286
  }
6287
+ /**
6288
+ * Fetches a single entry by its unique `slug`, including its multi-relation arrays.
6289
+ * @throws EntryNotFoundError if no row with `slug` exists in the seed's table.
6290
+ * @throws RepositoryError on any other database failure.
6291
+ */
5992
6292
  async findBySlug(seed, slug) {
5993
6293
  try {
5994
6294
  const tableName = this.getTableName(seed.slug);
@@ -6004,6 +6304,11 @@ var D1ContentRepository = class extends BaseD1Repository {
6004
6304
  throw this.mapError(error, `findBySlug(${seed.slug}, ${slug})`);
6005
6305
  }
6006
6306
  }
6307
+ /**
6308
+ * Computes aggregate facets for a seed: entry counts per `status`, and the distinct set
6309
+ * of tag values present across all entries for each `tags`-typed branch (via `json_each`
6310
+ * over the JSON-serialized tag column).
6311
+ */
6007
6312
  async getFacets(seed) {
6008
6313
  try {
6009
6314
  const tableName = this.getTableName(seed.slug);
@@ -6023,6 +6328,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6023
6328
  throw this.mapError(error, `getFacets(${seed.slug})`);
6024
6329
  }
6025
6330
  }
6331
+ /**
6332
+ * Checks whether `slug` is already taken in the seed's table.
6333
+ * @param excludeId When provided (e.g. during an update), excludes that entry's own row from the check.
6334
+ */
6026
6335
  async existsSlug(seed, slug, excludeId) {
6027
6336
  try {
6028
6337
  const tableName = this.getTableName(seed.slug);
@@ -6038,6 +6347,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6038
6347
  throw this.mapError(error, `existsSlug(${seed.slug}, ${slug})`);
6039
6348
  }
6040
6349
  }
6350
+ /**
6351
+ * Builds the `INSERT` statement for an entry's main table row. Multi-relation branch
6352
+ * values are skipped (they're written as separate junction-table inserts by the caller).
6353
+ */
6041
6354
  buildCreateMainStmt(seed, id, slug, status, data) {
6042
6355
  const tableName = this.getTableName(seed.slug);
6043
6356
  const columnNames = ["id", "slug", "status"];
@@ -6055,6 +6368,15 @@ var D1ContentRepository = class extends BaseD1Repository {
6055
6368
  const mainSql = `INSERT INTO ${tableName} (${columnNames.join(", ")}) VALUES (${placeholders.join(", ")})`;
6056
6369
  return this.database.prepare(mainSql).bind(...queryBindings);
6057
6370
  }
6371
+ /**
6372
+ * Builds the `UPDATE` statement for an entry's main table row from whichever branch
6373
+ * aliases are present in `data` (partial update — absent keys are left untouched).
6374
+ * Multi-relation branches present in `data` are returned as `junctionUpdates` instead
6375
+ * of being included in the SQL, since they require separate delete+insert statements.
6376
+ *
6377
+ * @returns `stmt: null` when there is nothing to update on the main row (no scalar
6378
+ * fields and no `status` change) even if junction updates are still needed.
6379
+ */
6058
6380
  buildUpdateMainStmt(seed, id, data, status) {
6059
6381
  const tableName = this.getTableName(seed.slug);
6060
6382
  const updateClauses = [];
@@ -6065,6 +6387,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6065
6387
  updateClauses.push("status = ?");
6066
6388
  queryBindings.push(status);
6067
6389
  }
6390
+ if (data.slug) {
6391
+ updateClauses.push("slug = ?");
6392
+ queryBindings.push(data.slug);
6393
+ }
6068
6394
  for (const branch of seed.branches) {
6069
6395
  if (mRelAliases.has(branch.alias)) continue;
6070
6396
  if (Object.hasOwn(data, branch.alias)) {
@@ -6084,18 +6410,28 @@ var D1ContentRepository = class extends BaseD1Repository {
6084
6410
  junctionUpdates
6085
6411
  };
6086
6412
  }
6413
+ /** Builds one `INSERT` per target id into a live multi-relation junction table, preserving array order via `position`. */
6087
6414
  buildJunctionInserts(seedSlug, parentId, branchAlias, targetIds) {
6088
6415
  const jt = jTable(seedSlug, branchAlias);
6089
6416
  return targetIds.map(
6090
6417
  (targetId, i) => this.database.prepare(`INSERT INTO ${jt} (parent_id, target_id, position) VALUES (?, ?, ?)`).bind(parentId, targetId, i)
6091
6418
  );
6092
6419
  }
6420
+ /** Builds one `INSERT` per target id into a draft multi-relation junction table, preserving array order via `position`. */
6093
6421
  buildDraftJunctionInserts(seedSlug, entryId, branchAlias, targetIds) {
6094
6422
  const jdt = jDraftTable(seedSlug, branchAlias);
6095
6423
  return targetIds.map(
6096
6424
  (targetId, i) => this.database.prepare(`INSERT INTO ${jdt} (entry_id, target_id, position) VALUES (?, ?, ?)`).bind(entryId, targetId, i)
6097
6425
  );
6098
6426
  }
6427
+ /**
6428
+ * Validates that every relation target referenced by a draft still exists before publish,
6429
+ * for both single-relation columns and multi-relation draft junction rows.
6430
+ *
6431
+ * @throws RelationTargetNotFoundError if any referenced target id is missing from its target seed's table.
6432
+ * @returns The multi-relation branches and their resolved (validated) draft target ids,
6433
+ * so the caller can reuse them without re-querying the draft junction tables.
6434
+ */
6099
6435
  async validatePublishDraftRelations(seed, draftRow, entryId) {
6100
6436
  for (const branch of singleRelBranches(seed)) {
6101
6437
  const value = draftRow[branch.alias];
@@ -6129,6 +6465,12 @@ var D1ContentRepository = class extends BaseD1Repository {
6129
6465
  }
6130
6466
  return { mRelBranches, mRelDraftIds };
6131
6467
  }
6468
+ /**
6469
+ * Builds the junction-table statements for one array-typed {@link BulkFieldUpdate}:
6470
+ * `array_replace` (delete-all + reinsert), `array_add` (append via `INSERT OR IGNORE`
6471
+ * with position computed from current max), or `array_remove` (targeted delete).
6472
+ * Returns an empty array for a no-op (e.g. `array_remove` with an empty value list).
6473
+ */
6132
6474
  getBulkArrayUpdateStmts(seedSlug, id, alias, update) {
6133
6475
  const jt = jTable(seedSlug, alias);
6134
6476
  const stmts = [];
@@ -6154,16 +6496,23 @@ var D1ContentRepository = class extends BaseD1Repository {
6154
6496
  }
6155
6497
  return stmts;
6156
6498
  }
6157
- async processBulkUpdateSingle(seedSlug, id, fields, tableName) {
6499
+ /**
6500
+ * Applies a bulk field update to a single entry as one batch: a main-table `UPDATE` for
6501
+ * `set`-kind fields plus any junction statements for array-kind fields.
6502
+ * @throws Error('not-found') if the main `UPDATE` affects zero rows (id doesn't exist).
6503
+ */
6504
+ async processBulkUpdateSingle(seed, id, fields, tableName) {
6158
6505
  const stmts = [];
6159
6506
  const setClauses = ["updated_at = (unixepoch())"];
6160
6507
  const setBindings = [];
6161
6508
  for (const [alias, update] of Object.entries(fields)) {
6162
6509
  if (update.kind === "set") {
6163
- setClauses.unshift(`${alias} = ?`);
6164
- setBindings.push(update.value);
6510
+ const branch = seed.branches.find((b) => b.alias === alias);
6511
+ const serializedValue = branch ? serializeForDb(branch, update.value) : update.value;
6512
+ setClauses.push(`${alias} = ?`);
6513
+ setBindings.push(serializedValue);
6165
6514
  } else {
6166
- stmts.push(...this.getBulkArrayUpdateStmts(seedSlug, id, alias, update));
6515
+ stmts.push(...this.getBulkArrayUpdateStmts(seed.slug, id, alias, update));
6167
6516
  }
6168
6517
  }
6169
6518
  stmts.unshift(
@@ -6174,12 +6523,17 @@ var D1ContentRepository = class extends BaseD1Repository {
6174
6523
  throw new Error("not-found");
6175
6524
  }
6176
6525
  }
6177
- async processBulkUpdateChunk(seedSlug, chunk, fields, tableName) {
6526
+ /**
6527
+ * Sequentially applies a bulk field update to each id in `chunk`, isolating per-id
6528
+ * failures (not-found, FK violation, or other error) into the `failed` list rather than
6529
+ * aborting the whole chunk.
6530
+ */
6531
+ async processBulkUpdateChunk(seed, chunk, fields, tableName) {
6178
6532
  let updated = 0;
6179
6533
  const failed = [];
6180
6534
  for (const id of chunk) {
6181
6535
  try {
6182
- await this.processBulkUpdateSingle(seedSlug, id, fields, tableName);
6536
+ await this.processBulkUpdateSingle(seed, id, fields, tableName);
6183
6537
  updated++;
6184
6538
  } catch (err) {
6185
6539
  const msg = err instanceof Error ? err.message : String(err);
@@ -6193,6 +6547,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6193
6547
  }
6194
6548
  return { updated, failed };
6195
6549
  }
6550
+ /**
6551
+ * Creates a new entry: runs `beforeCreate` (which may transform the payload), inserts the
6552
+ * main row plus any multi-relation junction rows as a single batch, then runs `afterCreate`.
6553
+ *
6554
+ * @throws SlugConflictError if `slug` already exists for this seed.
6555
+ * @throws RepositoryError on any other database failure.
6556
+ * @remarks `afterCreate` runs after the batch has committed, so it cannot roll back the write.
6557
+ */
6196
6558
  async create(seed, id, slug, status, data, options) {
6197
6559
  let payload = data;
6198
6560
  if (this.hooks?.beforeCreate) {
@@ -6201,7 +6563,7 @@ var D1ContentRepository = class extends BaseD1Repository {
6201
6563
  }
6202
6564
  try {
6203
6565
  if (await this.existsSlug(seed, slug)) {
6204
- throw new SlugConflictError3(`Slug "${slug}" already exists for ${seed.slug}`);
6566
+ throw new SlugConflictError4(`Slug "${slug}" already exists for ${seed.slug}`);
6205
6567
  }
6206
6568
  const batchStmts = [
6207
6569
  this.buildCreateMainStmt(seed, id, slug, status, payload)
@@ -6217,7 +6579,7 @@ var D1ContentRepository = class extends BaseD1Repository {
6217
6579
  await this.database.batch(batchStmts);
6218
6580
  }
6219
6581
  } catch (error) {
6220
- if (error instanceof SlugConflictError3) throw error;
6582
+ if (error instanceof SlugConflictError4) throw error;
6221
6583
  throw this.mapError(error, `create(${seed.slug})`);
6222
6584
  }
6223
6585
  if (this.hooks?.afterCreate) {
@@ -6225,6 +6587,15 @@ var D1ContentRepository = class extends BaseD1Repository {
6225
6587
  await this.hooks.afterCreate(entry, this.hookCtx(seed, options?.actor));
6226
6588
  }
6227
6589
  }
6590
+ /**
6591
+ * Partially updates an existing entry: runs `beforeUpdate`, applies main-row and
6592
+ * junction-table changes as a batch, then runs `afterUpdate`. A no-op payload (no scalar
6593
+ * fields, no multi-relation fields, no status change) returns without touching the database.
6594
+ *
6595
+ * @throws EntryNotFoundError if the main-row `UPDATE` affects zero rows.
6596
+ * @throws RepositoryError on any other database failure.
6597
+ * @remarks `afterUpdate` runs after the batch has committed, so it cannot roll back the write.
6598
+ */
6228
6599
  async update(seed, id, data, status, options) {
6229
6600
  let payload = data;
6230
6601
  if (this.hooks?.beforeUpdate) {
@@ -6262,6 +6633,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6262
6633
  await this.hooks.afterUpdate(entry, this.hookCtx(seed, options?.actor));
6263
6634
  }
6264
6635
  }
6636
+ /**
6637
+ * Deletes an entry: runs `beforeDelete`, reads the row (for the return value), deletes it,
6638
+ * then runs `afterDelete`. Relies on database-level `ON DELETE CASCADE` for junction rows.
6639
+ *
6640
+ * @returns The deserialized row as it existed immediately before deletion.
6641
+ * @throws EntryNotFoundError if no row with `id` exists.
6642
+ * @remarks `afterDelete` runs after the delete has committed, so it cannot roll back the write.
6643
+ */
6265
6644
  async delete(seed, id, options) {
6266
6645
  if (this.hooks?.beforeDelete) {
6267
6646
  await this.hooks.beforeDelete(id, this.hookCtx(seed, options?.actor));
@@ -6284,6 +6663,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6284
6663
  }
6285
6664
  return { row };
6286
6665
  }
6666
+ /**
6667
+ * Atomically increments/decrements a numeric field in a single conditional `UPDATE`
6668
+ * (guarded by optional `min`/`max` bounds evaluated against the new value), avoiding
6669
+ * read-modify-write races.
6670
+ *
6671
+ * @throws RepositoryError if `fieldName` isn't a `number`-typed branch on `seed`, or if
6672
+ * the conditional `UPDATE` affects zero rows (entry not found, or bound exceeded).
6673
+ */
6287
6674
  async mutateField(seed, id, fieldName, operation, options) {
6288
6675
  try {
6289
6676
  const branch = seed.branches.find((b) => b.alias === fieldName);
@@ -6313,6 +6700,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6313
6700
  throw this.mapError(error, `mutateField(${seed.slug}, ${id}, ${fieldName})`);
6314
6701
  }
6315
6702
  }
6703
+ /**
6704
+ * Executes a heterogeneous list of {@link BatchWrite} operations (create/update/mutateField)
6705
+ * as a single atomic `database.batch` call. Does not invoke lifecycle hooks — callers needing
6706
+ * hook side effects should use {@link create}/{@link update}/{@link mutateField} instead.
6707
+ *
6708
+ * @throws RepositoryError if any operation references a non-numeric field for `mutateField`,
6709
+ * or on any other database failure.
6710
+ */
6316
6711
  async runBatch(operations) {
6317
6712
  if (operations.length === 0) return;
6318
6713
  try {
@@ -6361,6 +6756,11 @@ var D1ContentRepository = class extends BaseD1Repository {
6361
6756
  throw this.mapError(error, "runBatch");
6362
6757
  }
6363
6758
  }
6759
+ /**
6760
+ * Upserts a draft row for `entryId` (`INSERT ... ON CONFLICT DO UPDATE` keyed on `entry_id`)
6761
+ * plus a full delete+reinsert of each multi-relation branch's draft junction rows.
6762
+ * @throws RepositoryError if `seed.allowDrafts` is false, or on any database failure.
6763
+ */
6364
6764
  async saveDraft(seed, entryId, data) {
6365
6765
  try {
6366
6766
  if (!seed.allowDrafts) {
@@ -6369,6 +6769,21 @@ var D1ContentRepository = class extends BaseD1Repository {
6369
6769
  const draftTableName = this.getTableName(seed.slug, true);
6370
6770
  const mRelBranches = multiRelBranches(seed);
6371
6771
  const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
6772
+ let existingTouched = [];
6773
+ try {
6774
+ const existing = await this.database.prepare(`SELECT _touched_fields FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId).first();
6775
+ if (existing && existing._touched_fields) {
6776
+ existingTouched = JSON.parse(existing._touched_fields);
6777
+ }
6778
+ } catch {
6779
+ }
6780
+ const currentTouched = new Set(existingTouched);
6781
+ for (const branch of seed.branches) {
6782
+ if (Object.hasOwn(data, branch.alias)) {
6783
+ currentTouched.add(branch.alias);
6784
+ }
6785
+ }
6786
+ const updatedTouchedFields = Array.from(currentTouched);
6372
6787
  const columnNames = ["entry_id"];
6373
6788
  const placeholders = ["?"];
6374
6789
  const queryBindings = [entryId];
@@ -6383,6 +6798,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6383
6798
  updateClauses.push(`${branch.alias} = EXCLUDED.${branch.alias}`);
6384
6799
  }
6385
6800
  }
6801
+ columnNames.push("_touched_fields");
6802
+ placeholders.push("?");
6803
+ queryBindings.push(JSON.stringify(updatedTouchedFields));
6804
+ updateClauses.push(`_touched_fields = EXCLUDED._touched_fields`);
6386
6805
  updateClauses.push("updated_at = (unixepoch())");
6387
6806
  const sql = `
6388
6807
  INSERT INTO ${draftTableName} (${columnNames.join(", ")})
@@ -6414,6 +6833,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6414
6833
  throw this.mapError(error, `saveDraft(${seed.slug}, ${entryId})`);
6415
6834
  }
6416
6835
  }
6836
+ /**
6837
+ * Fetches the draft row for `entryId`, if any, merged with its multi-relation draft arrays.
6838
+ * @returns `null` if `seed.allowDrafts` is false or no draft row exists.
6839
+ */
6417
6840
  async getDraft(seed, entryId) {
6418
6841
  try {
6419
6842
  if (!seed.allowDrafts) return null;
@@ -6422,10 +6845,20 @@ var D1ContentRepository = class extends BaseD1Repository {
6422
6845
  if (!draftRow) return null;
6423
6846
  const mRelBranches = multiRelBranches(seed);
6424
6847
  const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
6848
+ let touchedFields = [];
6849
+ if (draftRow && typeof draftRow["_touched_fields"] === "string") {
6850
+ try {
6851
+ touchedFields = JSON.parse(draftRow["_touched_fields"]);
6852
+ } catch {
6853
+ }
6854
+ }
6855
+ const touchedSet = new Set(touchedFields);
6425
6856
  const draftData = {};
6426
6857
  for (const branch of seed.branches) {
6427
6858
  if (mRelAliases.has(branch.alias)) continue;
6428
- if (draftRow[branch.alias] !== null) {
6859
+ if (touchedSet.has(branch.alias)) {
6860
+ draftData[branch.alias] = draftRow[branch.alias] !== null ? deserializeFromDb2(branch, draftRow[branch.alias]) : null;
6861
+ } else if (draftRow[branch.alias] !== null) {
6429
6862
  draftData[branch.alias] = deserializeFromDb2(branch, draftRow[branch.alias]);
6430
6863
  }
6431
6864
  }
@@ -6435,9 +6868,12 @@ var D1ContentRepository = class extends BaseD1Repository {
6435
6868
  );
6436
6869
  const results = await this.database.batch(stmts);
6437
6870
  for (let i = 0; i < mRelBranches.length; i++) {
6871
+ const alias = mRelBranches[i].alias;
6438
6872
  const ids = (results[i].results ?? []).map((r) => r.target_id);
6439
- if (ids.length > 0) {
6440
- draftData[mRelBranches[i].alias] = ids;
6873
+ if (touchedSet.has(alias)) {
6874
+ draftData[alias] = ids;
6875
+ } else if (ids.length > 0) {
6876
+ draftData[alias] = ids;
6441
6877
  }
6442
6878
  }
6443
6879
  }
@@ -6446,6 +6882,7 @@ var D1ContentRepository = class extends BaseD1Repository {
6446
6882
  throw this.mapError(error, `getDraft(${seed.slug}, ${entryId})`);
6447
6883
  }
6448
6884
  }
6885
+ /** Checks whether a draft row exists for `entryId`. Always `false` if `seed.allowDrafts` is false. */
6449
6886
  async hasDraft(seed, entryId) {
6450
6887
  try {
6451
6888
  if (!seed.allowDrafts) return false;
@@ -6456,6 +6893,15 @@ var D1ContentRepository = class extends BaseD1Repository {
6456
6893
  throw this.mapError(error, `hasDraft(${seed.slug}, ${entryId})`);
6457
6894
  }
6458
6895
  }
6896
+ /**
6897
+ * Promotes a draft to the live entry: validates all relation targets referenced by the
6898
+ * draft still exist, copies the draft's scalar fields into the live row, replaces the
6899
+ * live junction rows with the draft's, and deletes the draft — all as one batch.
6900
+ * A no-op if `seed.allowDrafts` is false.
6901
+ *
6902
+ * @throws EntryNotFoundError if no draft row exists for `entryId`.
6903
+ * @throws RelationTargetNotFoundError if any relation the draft references no longer exists.
6904
+ */
6459
6905
  async publishDraft(seed, entryId) {
6460
6906
  try {
6461
6907
  if (!seed.allowDrafts) return;
@@ -6465,33 +6911,56 @@ var D1ContentRepository = class extends BaseD1Repository {
6465
6911
  if (!draftRow) {
6466
6912
  throw new EntryNotFoundError7(`No draft found for ${entryId} in ${seed.slug}`);
6467
6913
  }
6914
+ let touchedFields = [];
6915
+ if (draftRow && typeof draftRow["_touched_fields"] === "string") {
6916
+ try {
6917
+ touchedFields = JSON.parse(draftRow["_touched_fields"]);
6918
+ } catch {
6919
+ }
6920
+ }
6921
+ const touchedSet = new Set(touchedFields);
6468
6922
  const { mRelBranches, mRelDraftIds } = await this.validatePublishDraftRelations(seed, draftRow, entryId);
6469
6923
  const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
6470
6924
  const updateClauses = [];
6471
6925
  const queryBindings = [];
6472
6926
  for (const branch of seed.branches) {
6473
6927
  if (mRelAliases.has(branch.alias)) continue;
6474
- if (draftRow[branch.alias] !== null) {
6928
+ if (touchedSet.has(branch.alias)) {
6929
+ updateClauses.push(`${branch.alias} = ?`);
6930
+ queryBindings.push(draftRow[branch.alias]);
6931
+ } else if (draftRow[branch.alias] !== null) {
6475
6932
  updateClauses.push(`${branch.alias} = ?`);
6476
6933
  queryBindings.push(draftRow[branch.alias]);
6477
6934
  }
6478
6935
  }
6479
- updateClauses.push("updated_at = (unixepoch())");
6480
- const updateSql = `UPDATE ${liveTableName} SET ${updateClauses.join(", ")} WHERE id = ?`;
6481
- queryBindings.push(entryId);
6936
+ let updateSql = "";
6937
+ if (updateClauses.length > 0) {
6938
+ updateClauses.push("updated_at = (unixepoch())");
6939
+ updateSql = `UPDATE ${liveTableName} SET ${updateClauses.join(", ")} WHERE id = ?`;
6940
+ queryBindings.push(entryId);
6941
+ } else {
6942
+ updateSql = `UPDATE ${liveTableName} SET updated_at = (unixepoch()) WHERE id = ?`;
6943
+ queryBindings.push(entryId);
6944
+ }
6482
6945
  const batchStmts = [
6483
6946
  this.database.prepare(updateSql).bind(...queryBindings),
6484
6947
  this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId)
6485
6948
  ];
6486
6949
  for (const branch of mRelBranches) {
6487
- const lt = jTable(seed.slug, branch.alias);
6488
6950
  const jdt = jDraftTable(seed.slug, branch.alias);
6489
- const targetIds = mRelDraftIds.get(branch.alias) ?? [];
6490
- batchStmts.push(
6491
- this.database.prepare(`DELETE FROM ${lt} WHERE parent_id = ?`).bind(entryId),
6492
- this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId),
6493
- ...this.buildJunctionInserts(seed.slug, entryId, branch.alias, targetIds)
6494
- );
6951
+ if (touchedSet.has(branch.alias)) {
6952
+ const lt = jTable(seed.slug, branch.alias);
6953
+ const targetIds = mRelDraftIds.get(branch.alias) ?? [];
6954
+ batchStmts.push(
6955
+ this.database.prepare(`DELETE FROM ${lt} WHERE parent_id = ?`).bind(entryId),
6956
+ this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId),
6957
+ ...this.buildJunctionInserts(seed.slug, entryId, branch.alias, targetIds)
6958
+ );
6959
+ } else {
6960
+ batchStmts.push(
6961
+ this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId)
6962
+ );
6963
+ }
6495
6964
  }
6496
6965
  await this.database.batch(batchStmts);
6497
6966
  } catch (error) {
@@ -6500,19 +6969,25 @@ var D1ContentRepository = class extends BaseD1Repository {
6500
6969
  throw this.mapError(error, `publishDraft(${seed.slug}, ${entryId})`);
6501
6970
  }
6502
6971
  }
6503
- async bulkUpdate(seedSlug, ids, fields) {
6972
+ /**
6973
+ * Applies the same set of field updates across many entries, chunked at 50 ids per batch
6974
+ * to bound per-call payload size. Per-id failures are collected rather than aborting the
6975
+ * whole operation — see {@link processBulkUpdateSingle} for failure classification.
6976
+ */
6977
+ async bulkUpdate(seed, ids, fields) {
6504
6978
  const CHUNK = 50;
6505
- const tableName = this.getTableName(seedSlug);
6979
+ const tableName = this.getTableName(seed.slug);
6506
6980
  let updated = 0;
6507
6981
  const failed = [];
6508
6982
  for (let offset = 0; offset < ids.length; offset += CHUNK) {
6509
6983
  const chunk = ids.slice(offset, offset + CHUNK);
6510
- const res = await this.processBulkUpdateChunk(seedSlug, chunk, fields, tableName);
6984
+ const res = await this.processBulkUpdateChunk(seed, chunk, fields, tableName);
6511
6985
  updated += res.updated;
6512
6986
  failed.push(...res.failed);
6513
6987
  }
6514
6988
  return { updated, failed };
6515
6989
  }
6990
+ /** Deletes the draft row for `entryId`, if any. A no-op if `seed.allowDrafts` is false. */
6516
6991
  async deleteDraft(seed, entryId) {
6517
6992
  try {
6518
6993
  if (!seed.allowDrafts) return;
@@ -6522,6 +6997,15 @@ var D1ContentRepository = class extends BaseD1Repository {
6522
6997
  throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`);
6523
6998
  }
6524
6999
  }
7000
+ /**
7001
+ * Finds every pending draft across the given seeds, `UNION ALL`-ing one query per seed
7002
+ * so results span heterogeneous `content_{slug}_drafts` tables, then left-joins each
7003
+ * against `activity_logs` to attribute the most recent "draft saved" action.
7004
+ *
7005
+ * @param seeds Seeds to search; drafts are looked up per-seed since draft tables aren't unified.
7006
+ * @returns Draft summaries ordered by most-recently-updated first. `title` falls back to the
7007
+ * draft's own `id` if the display-name column is blank on both the draft and its live counterpart.
7008
+ */
6525
7009
  async findPendingDrafts(seeds) {
6526
7010
  try {
6527
7011
  if (seeds.length === 0) return [];
@@ -6531,6 +7015,8 @@ var D1ContentRepository = class extends BaseD1Repository {
6531
7015
  const draftTable = `content_${seed.slug}_drafts`;
6532
7016
  const liveTable = `content_${seed.slug}`;
6533
7017
  const titleCol = seed.displayNameAlias;
7018
+ const isScalar = seed.branches.some((b) => b.alias === titleCol && !(b.type === "relation" && b.multiple === true));
7019
+ const titleExpr = isScalar ? `COALESCE(d.${titleCol}, l.${titleCol})` : `COALESCE(l.slug, d.entry_id)`;
6534
7020
  const seedLabel = seed.labelPlural ?? seed.label;
6535
7021
  unionSelects.push(`
6536
7022
  SELECT
@@ -6538,7 +7024,7 @@ var D1ContentRepository = class extends BaseD1Repository {
6538
7024
  ? AS seed_label,
6539
7025
  d.entry_id AS id,
6540
7026
  d.updated_at AS updated_at,
6541
- COALESCE(d.${titleCol}, l.${titleCol}) AS title
7027
+ ${titleExpr} AS title
6542
7028
  FROM ${draftTable} d
6543
7029
  LEFT JOIN ${liveTable} l ON l.id = d.entry_id
6544
7030
  `);
@@ -6585,9 +7071,48 @@ var D1ContentRepository = class extends BaseD1Repository {
6585
7071
  throw this.mapError(error, "findPendingDrafts");
6586
7072
  }
6587
7073
  }
7074
+ /**
7075
+ * Updates an entry's field values (optional) and its kanban board position atomically in one
7076
+ * batch: an optional main-row/junction update via {@link buildUpdateMainStmt}, plus an upsert
7077
+ * into `kanban_positions` keyed on `(seed_slug, entry_id, axis_branch_id)`.
7078
+ *
7079
+ * @param patch Field changes to apply alongside the drag/drop, or `null` if only position changed.
7080
+ * @param position Opaque fractional-index (or similar) position value used for ordering within the axis bucket.
7081
+ * @param axisBranchId Identifies which branch/column axis this position is relative to (e.g. status column).
7082
+ * @remarks Unlike {@link create}/{@link update}, this method does not invoke `beforeUpdate`/`afterUpdate`
7083
+ * lifecycle hooks — the `_ctx.actor` parameter is currently unused. Kanban drag-and-drop writes
7084
+ * therefore bypass hook-based side effects (e.g. audit logging) that `update()` would trigger.
7085
+ */
7086
+ async updateWithKanbanPosition(seed, id, patch, position, axisBranchId, _ctx) {
7087
+ try {
7088
+ const stmts = [];
7089
+ if (patch) {
7090
+ const { stmt, junctionUpdates } = this.buildUpdateMainStmt(seed, id, patch);
7091
+ if (stmt) stmts.push(stmt);
7092
+ for (const branch of junctionUpdates) {
7093
+ const jt = jTable(seed.slug, branch.alias);
7094
+ const value = patch[branch.alias] ?? [];
7095
+ stmts.push(this.database.prepare(`DELETE FROM ${jt} WHERE parent_id = ?`).bind(id));
7096
+ stmts.push(...this.buildJunctionInserts(seed.slug, id, branch.alias, value));
7097
+ }
7098
+ }
7099
+ stmts.push(
7100
+ this.database.prepare(`
7101
+ INSERT INTO kanban_positions (seed_slug, entry_id, axis_branch_id, position, updated_at)
7102
+ VALUES (?, ?, ?, ?, unixepoch())
7103
+ ON CONFLICT(seed_slug, entry_id, axis_branch_id)
7104
+ DO UPDATE SET position = excluded.position, updated_at = excluded.updated_at
7105
+ `).bind(seed.slug, id, axisBranchId, position)
7106
+ );
7107
+ await this.database.batch(stmts);
7108
+ return { success: true };
7109
+ } catch (error) {
7110
+ throw this.mapError(error, `updateWithKanbanPosition(${seed.slug}, ${id})`);
7111
+ }
7112
+ }
6588
7113
  };
6589
7114
 
6590
- // src/shared/idempotency.repository.d1.ts
7115
+ // src/shared/db/repositories/idempotency.repository.d1.ts
6591
7116
  var D1IdempotencyRepository = class extends BaseD1Repository {
6592
7117
  /**
6593
7118
  * Looks up an idempotency record by its key.
@@ -6638,7 +7163,7 @@ var D1IdempotencyRepository = class extends BaseD1Repository {
6638
7163
  }
6639
7164
  };
6640
7165
 
6641
- // src/shared/media.repository.d1.ts
7166
+ // src/shared/db/repositories/media.repository.d1.ts
6642
7167
  var D1MediaRepository = class {
6643
7168
  constructor(database) {
6644
7169
  this.database = database;
@@ -6694,7 +7219,7 @@ var D1MediaRepository = class {
6694
7219
  }
6695
7220
  };
6696
7221
 
6697
- // src/shared/system-stats.repository.d1.ts
7222
+ // src/shared/db/repositories/system-stats.repository.d1.ts
6698
7223
  var D1SystemStatsRepository = class {
6699
7224
  constructor(database) {
6700
7225
  this.database = database;
@@ -6733,7 +7258,7 @@ var D1SystemStatsRepository = class {
6733
7258
  }
6734
7259
  };
6735
7260
 
6736
- // src/shared/d1-user.repository.ts
7261
+ // src/shared/db/repositories/d1-user.repository.ts
6737
7262
  function rowToRecord(row) {
6738
7263
  return {
6739
7264
  id: row.id,
@@ -6800,7 +7325,7 @@ var D1UserRepository = class {
6800
7325
  }
6801
7326
  };
6802
7327
 
6803
- // src/shared/d1-session.repository.ts
7328
+ // src/shared/db/repositories/d1-session.repository.ts
6804
7329
  function rowToRecord2(row) {
6805
7330
  return {
6806
7331
  id: row.id,
@@ -6859,7 +7384,7 @@ var D1SessionRepository = class {
6859
7384
  }
6860
7385
  };
6861
7386
 
6862
- // src/shared/d1-password-reset-token.repository.ts
7387
+ // src/shared/db/repositories/d1-password-reset-token.repository.ts
6863
7388
  var D1PasswordResetTokenRepository = class {
6864
7389
  constructor(db, idGenerator) {
6865
7390
  this.db = db;
@@ -6889,7 +7414,7 @@ var D1PasswordResetTokenRepository = class {
6889
7414
  }
6890
7415
  };
6891
7416
 
6892
- // src/shared/d1-activity-log.repository.ts
7417
+ // src/shared/db/repositories/d1-activity-log.repository.ts
6893
7418
  var D1ActivityLogRepository = class {
6894
7419
  constructor(database) {
6895
7420
  this.database = database;
@@ -6952,7 +7477,7 @@ function parseDetails(raw) {
6952
7477
  }
6953
7478
  }
6954
7479
 
6955
- // src/shared/d1-notification.repository.ts
7480
+ // src/shared/db/repositories/d1-notification.repository.ts
6956
7481
  var D1NotificationRepository = class {
6957
7482
  constructor(database, clock, idGenerator) {
6958
7483
  this.database = database;
@@ -7016,7 +7541,7 @@ function mapRowToRecord2(row) {
7016
7541
  };
7017
7542
  }
7018
7543
 
7019
- // src/shared/d1-widget.repository.ts
7544
+ // src/shared/db/repositories/d1-widget.repository.ts
7020
7545
  import {
7021
7546
  isDateRange
7022
7547
  } from "@beechcms/core";
@@ -7090,9 +7615,10 @@ var D1WidgetRepository = class {
7090
7615
  const bindings = [];
7091
7616
  const trimmedSearch = options.search?.trim() ?? "";
7092
7617
  if (trimmedSearch.length > 0) {
7618
+ const escapedSearch = trimmedSearch.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
7093
7619
  const displayColumn = this.resolveColumnExpression(seed, seed.displayNameAlias);
7094
- conditions.push(`${displayColumn} LIKE ?`);
7095
- bindings.push(`%${trimmedSearch}%`);
7620
+ conditions.push(`${displayColumn} LIKE ? ESCAPE '\\'`);
7621
+ bindings.push(`%${escapedSearch}%`);
7096
7622
  }
7097
7623
  for (const filter of options.filters ?? []) {
7098
7624
  this.appendFilterCondition(seed, filter, conditions, bindings);
@@ -7293,7 +7819,7 @@ var D1WidgetRepository = class {
7293
7819
  }
7294
7820
  };
7295
7821
 
7296
- // src/shared/d1-search.repository.ts
7822
+ // src/shared/db/repositories/d1-search.repository.ts
7297
7823
  var EMPTY_QUERY_ERROR = "EMPTY_QUERY";
7298
7824
  var D1SearchRepository = class {
7299
7825
  constructor(database) {
@@ -7353,7 +7879,7 @@ function mapFtsRowToResultRow(row) {
7353
7879
  };
7354
7880
  }
7355
7881
 
7356
- // src/shared/d1-analytics.repository.ts
7882
+ // src/shared/db/repositories/d1-analytics.repository.ts
7357
7883
  var SECONDS_PER_DAY2 = 86400;
7358
7884
  var D1AnalyticsRepository = class {
7359
7885
  constructor(database, clock) {
@@ -7405,7 +7931,7 @@ var D1AnalyticsRepository = class {
7405
7931
  }
7406
7932
  };
7407
7933
 
7408
- // src/shared/d1-content-scan.repository.ts
7934
+ // src/shared/db/repositories/d1-content-scan.repository.ts
7409
7935
  var D1ContentScanRepository = class {
7410
7936
  constructor(db) {
7411
7937
  this.db = db;
@@ -7434,7 +7960,7 @@ var D1ContentScanRepository = class {
7434
7960
  }
7435
7961
  };
7436
7962
 
7437
- // src/shared/site-settings.repository.d1.ts
7963
+ // src/shared/db/repositories/site-settings.repository.d1.ts
7438
7964
  var DEFAULTS = {
7439
7965
  siteTitle: "Beech CMS",
7440
7966
  defaultLanguage: "en",
@@ -7474,7 +8000,7 @@ var D1SiteSettingsRepository = class {
7474
8000
  }
7475
8001
  };
7476
8002
 
7477
- // src/shared/demo-data-sql.ts
8003
+ // src/shared/db/migrations/demo-data-sql.ts
7478
8004
  var DEMO_DATA_SQL = `
7479
8005
  INSERT OR IGNORE INTO content_clienti (id, slug, status, name, email, company, tier, account_status, mrr, created_at, updated_at)
7480
8006
  VALUES
@@ -8453,7 +8979,7 @@ VALUES
8453
8979
 
8454
8980
  `;
8455
8981
 
8456
- // src/shared/demo-data.repository.d1.ts
8982
+ // src/shared/db/repositories/demo-data.repository.d1.ts
8457
8983
  var D1DemoDataRepository = class {
8458
8984
  constructor(db) {
8459
8985
  this.db = db;
@@ -8465,7 +8991,7 @@ var D1DemoDataRepository = class {
8465
8991
  }
8466
8992
  };
8467
8993
 
8468
- // src/shared/d1-setup-checklist.repository.ts
8994
+ // src/shared/db/repositories/d1-setup-checklist.repository.ts
8469
8995
  var D1SetupChecklistRepository = class {
8470
8996
  constructor(db) {
8471
8997
  this.db = db;
@@ -8485,7 +9011,8 @@ var D1SetupChecklistRepository = class {
8485
9011
  }
8486
9012
  };
8487
9013
 
8488
- // src/shared/seed-layout.repository.d1.ts
9014
+ // src/shared/db/repositories/seed-layout.repository.d1.ts
9015
+ import { seedViewConfigSchema as seedViewConfigSchema2 } from "@beechcms/core";
8489
9016
  var D1SeedLayoutRepository = class {
8490
9017
  constructor(db) {
8491
9018
  this.db = db;
@@ -8527,9 +9054,25 @@ var D1SeedLayoutRepository = class {
8527
9054
  async remove(slug) {
8528
9055
  await this.db.prepare("DELETE FROM seed_layouts WHERE slug = ?").bind(slug).run();
8529
9056
  }
9057
+ async getViewConfig(slug) {
9058
+ const row = await this.db.prepare("SELECT view_config FROM seed_layouts WHERE slug = ?").bind(slug).first();
9059
+ if (!row?.view_config) return null;
9060
+ return seedViewConfigSchema2.parse(JSON.parse(row.view_config));
9061
+ }
9062
+ async setViewConfig(slug, config, updatedBy) {
9063
+ const json = JSON.stringify(config);
9064
+ await this.db.prepare(`
9065
+ INSERT INTO seed_layouts (slug, layout, view_config, updated_at, updated_by)
9066
+ VALUES (?, '{}', ?, unixepoch(), ?)
9067
+ ON CONFLICT(slug) DO UPDATE SET
9068
+ view_config = excluded.view_config,
9069
+ updated_at = excluded.updated_at,
9070
+ updated_by = excluded.updated_by
9071
+ `).bind(slug, json, updatedBy).run();
9072
+ }
8530
9073
  };
8531
9074
 
8532
- // src/shared/dashboard-layout.repository.d1.ts
9075
+ // src/shared/db/repositories/dashboard-layout.repository.d1.ts
8533
9076
  var D1DashboardLayoutRepository = class {
8534
9077
  constructor(db) {
8535
9078
  this.db = db;
@@ -8570,7 +9113,7 @@ var D1DashboardLayoutRepository = class {
8570
9113
  }
8571
9114
  };
8572
9115
 
8573
- // src/shared/seed.repository.d1.ts
9116
+ // src/shared/db/repositories/seed.repository.d1.ts
8574
9117
  var D1SeedRepository = class {
8575
9118
  constructor(db) {
8576
9119
  this.db = db;
@@ -8645,7 +9188,7 @@ var D1SeedRepository = class {
8645
9188
  }
8646
9189
  };
8647
9190
 
8648
- // src/shared/schema-mutator.d1.ts
9191
+ // src/shared/db/migrations/schema-mutator.d1.ts
8649
9192
  var D1SchemaMutator = class _D1SchemaMutator {
8650
9193
  constructor(db) {
8651
9194
  this.db = db;
@@ -8688,13 +9231,59 @@ var D1SchemaMutator = class _D1SchemaMutator {
8688
9231
  if (statements.length === 0) return;
8689
9232
  await this.db.batch(statements.map((s) => this.db.prepare(s)));
8690
9233
  }
9234
+ async fetchRows(table, columns) {
9235
+ _D1SchemaMutator.assertIdentifier(table);
9236
+ for (const col of columns) _D1SchemaMutator.assertIdentifier(col);
9237
+ const colList = columns.join(", ");
9238
+ const rs = await this.db.prepare(`SELECT ${colList} FROM ${table}`).all();
9239
+ return rs.results ?? [];
9240
+ }
9241
+ };
9242
+
9243
+ // src/shared/db/repositories/kanban-position.repository.d1.ts
9244
+ var D1KanbanPositionRepository = class {
9245
+ constructor(db) {
9246
+ this.db = db;
9247
+ }
9248
+ db;
9249
+ async getColumn(seedSlug, axisBranchId) {
9250
+ const rs = await this.db.prepare("SELECT entry_id, position FROM kanban_positions WHERE seed_slug = ? AND axis_branch_id = ?").bind(seedSlug, axisBranchId).all();
9251
+ const map = /* @__PURE__ */ new Map();
9252
+ for (const row of rs.results ?? []) {
9253
+ map.set(row.entry_id, row.position);
9254
+ }
9255
+ return map;
9256
+ }
9257
+ async setPosition(seedSlug, entryId, axisBranchId, position) {
9258
+ await this.db.prepare(`
9259
+ INSERT INTO kanban_positions (seed_slug, entry_id, axis_branch_id, position, updated_at)
9260
+ VALUES (?, ?, ?, ?, unixepoch())
9261
+ ON CONFLICT(seed_slug, entry_id, axis_branch_id)
9262
+ DO UPDATE SET position = excluded.position, updated_at = excluded.updated_at
9263
+ `).bind(seedSlug, entryId, axisBranchId, position).run();
9264
+ }
9265
+ async remove(seedSlug, entryId, axisBranchId) {
9266
+ await this.db.prepare("DELETE FROM kanban_positions WHERE seed_slug = ? AND entry_id = ? AND axis_branch_id = ?").bind(seedSlug, entryId, axisBranchId).run();
9267
+ }
9268
+ async rebalance(seedSlug, axisBranchId, ordered) {
9269
+ if (ordered.length === 0) return;
9270
+ const stmts = ordered.map(
9271
+ (r) => 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, r.entryId, axisBranchId, r.position)
9277
+ );
9278
+ await this.db.batch(stmts);
9279
+ }
8691
9280
  };
8692
9281
 
8693
9282
  // src/middleware/repository.middleware.ts
8694
9283
  import { SystemClock as SystemClock2, SystemIdGenerator } from "@beechcms/core";
8695
9284
  import { NoOpScheduler } from "@beechcms/core";
8696
9285
 
8697
- // src/shared/automations.repository.d1.ts
9286
+ // src/shared/db/repositories/automations.repository.d1.ts
8698
9287
  var D1AutomationRepository = class {
8699
9288
  constructor(db) {
8700
9289
  this.db = db;
@@ -8780,7 +9369,7 @@ function rowToAutomation(row) {
8780
9369
  };
8781
9370
  }
8782
9371
 
8783
- // src/shared/execution-context-scheduler.ts
9372
+ // src/shared/services/scheduler/execution-context-scheduler.ts
8784
9373
  var ExecutionContextScheduler = class {
8785
9374
  constructor(ctx) {
8786
9375
  this.ctx = ctx;
@@ -8839,6 +9428,7 @@ var repositoryMiddleware = (overrides) => {
8839
9428
  context.set("seedRepository", overrides?.seedRepository ?? new D1SeedRepository(database));
8840
9429
  context.set("schemaMutator", overrides?.schemaMutator ?? new D1SchemaMutator(database));
8841
9430
  context.set("dashboardLayoutRepository", overrides?.dashboardLayoutRepository ?? new D1DashboardLayoutRepository(database));
9431
+ context.set("kanbanPositionRepository", overrides?.kanbanPositionRepository ?? new D1KanbanPositionRepository(database));
8842
9432
  await next();
8843
9433
  });
8844
9434
  };
@@ -8846,7 +9436,7 @@ var repositoryMiddleware = (overrides) => {
8846
9436
  // src/middleware/seed-registry.middleware.ts
8847
9437
  import { createMiddleware as createMiddleware3 } from "hono/factory";
8848
9438
 
8849
- // src/shared/seed-registry-cache.ts
9439
+ // src/shared/services/cache/seed-registry-cache.ts
8850
9440
  import { SeedRegistry, buildBackrefMap } from "@beechcms/core";
8851
9441
  var cache = null;
8852
9442
  var TTL_MS = 5e3;
@@ -8906,30 +9496,12 @@ var S3Bucket = class {
8906
9496
  const command = new PutObjectCommand({
8907
9497
  Bucket: this.bucketName,
8908
9498
  Key: key,
8909
- Body: body instanceof ReadableStream ? await this.streamToUint8Array(body) : new Uint8Array(body),
9499
+ Body: body instanceof ReadableStream ? body : new Uint8Array(body),
8910
9500
  ContentType: options?.contentType,
8911
9501
  Metadata: options?.metadata
8912
9502
  });
8913
9503
  await this.client.send(command);
8914
9504
  }
8915
- async streamToUint8Array(stream) {
8916
- const reader = stream.getReader();
8917
- const chunks = [];
8918
- let totalLength = 0;
8919
- while (true) {
8920
- const { done, value } = await reader.read();
8921
- if (done) break;
8922
- chunks.push(value);
8923
- totalLength += value.length;
8924
- }
8925
- const result = new Uint8Array(totalLength);
8926
- let offset = 0;
8927
- for (const chunk of chunks) {
8928
- result.set(chunk, offset);
8929
- offset += chunk.length;
8930
- }
8931
- return result;
8932
- }
8933
9505
  async get(key) {
8934
9506
  try {
8935
9507
  const command = new GetObjectCommand({
@@ -8978,10 +9550,11 @@ var S3Bucket = class {
8978
9550
  }
8979
9551
  }
8980
9552
  getUrl(key) {
9553
+ const encodedKey = key.split("/").map(encodeURIComponent).join("/");
8981
9554
  if (this.cdnUrl) {
8982
- return `${this.cdnUrl}/${encodeURIComponent(key)}`;
9555
+ return `${this.cdnUrl}/${encodedKey}`;
8983
9556
  }
8984
- return `${this.baseUrl}/api/media/${encodeURIComponent(key)}`;
9557
+ return `${this.baseUrl}/api/media/${encodedKey}`;
8985
9558
  }
8986
9559
  async getTotalSize() {
8987
9560
  let total = 0;
@@ -9003,7 +9576,8 @@ var S3Bucket = class {
9003
9576
  const command = new PutObjectCommand({
9004
9577
  Bucket: this.bucketName,
9005
9578
  Key: key,
9006
- ContentType: options.contentType
9579
+ ContentType: options.contentType,
9580
+ ContentLength: options.contentLength
9007
9581
  });
9008
9582
  return getSignedUrl(this.client, command, { expiresIn: options.expiresIn });
9009
9583
  }
@@ -9033,9 +9607,21 @@ var S3Bucket = class {
9033
9607
  };
9034
9608
 
9035
9609
  // src/shared/storage/factory.ts
9610
+ import { HTTPException as HTTPException2 } from "hono/http-exception";
9036
9611
  var NullBucket = class {
9037
9612
  fail() {
9038
- throw new Error("Storage not configured. Set R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME.");
9613
+ throw new HTTPException2(503, {
9614
+ res: new Response(
9615
+ JSON.stringify({
9616
+ error: "storage_not_configured",
9617
+ message: "Storage is not configured. Please set R2 credentials or run MinIO."
9618
+ }),
9619
+ {
9620
+ status: 503,
9621
+ headers: { "Content-Type": "application/json" }
9622
+ }
9623
+ )
9624
+ });
9039
9625
  }
9040
9626
  put() {
9041
9627
  return this.fail();
@@ -9092,7 +9678,7 @@ var storageMiddleware = (overrides) => {
9092
9678
  });
9093
9679
  };
9094
9680
 
9095
- // src/shared/in-memory-seed.repository.ts
9681
+ // src/shared/db/repositories/in-memory-seed.repository.ts
9096
9682
  var InMemorySeedRepository = class {
9097
9683
  seeds;
9098
9684
  version = 1;
@@ -9138,7 +9724,7 @@ var InMemorySeedRepository = class {
9138
9724
  import { createMiddleware as createMiddleware5 } from "hono/factory";
9139
9725
  import { SystemClock as SystemClock3 } from "@beechcms/core";
9140
9726
 
9141
- // src/auth/bcrypt-hash-provider.ts
9727
+ // src/auth/providers/hash.provider.ts
9142
9728
  import bcrypt from "bcryptjs";
9143
9729
  var BCRYPT_SALT_ROUNDS = 10;
9144
9730
  var BcryptHashProvider = class {
@@ -9154,7 +9740,7 @@ var BcryptHashProvider = class {
9154
9740
  }
9155
9741
  };
9156
9742
 
9157
- // src/auth/jose-token-service.ts
9743
+ // src/auth/providers/jwt-token.service.ts
9158
9744
  import { SignJWT, jwtVerify } from "jose";
9159
9745
  var DEFAULT_TOKEN_TTL_SECONDS = 900;
9160
9746
  var DEFAULT_ALGORITHM = "HS256";
@@ -9186,8 +9772,12 @@ var JoseTokenService = class {
9186
9772
  const { payload } = await jwtVerify(token, this.secretBytes, {
9187
9773
  algorithms: [algorithm],
9188
9774
  issuer: this.config.issuer,
9189
- audience: this.config.audience
9775
+ audience: this.config.audience,
9776
+ currentDate: new Date(this.clock.nowSeconds() * 1e3)
9190
9777
  });
9778
+ if (typeof payload.sub !== "string" || payload.sub === "") {
9779
+ return null;
9780
+ }
9191
9781
  return payload;
9192
9782
  } catch {
9193
9783
  return null;
@@ -9225,13 +9815,23 @@ import { createMiddleware as createMiddleware6 } from "hono/factory";
9225
9815
 
9226
9816
  // src/rate-limit/cloudflare-rate-limiter.ts
9227
9817
  var CloudflareRateLimiter = class {
9228
- constructor(binding) {
9818
+ constructor(binding, options = {}) {
9229
9819
  this.binding = binding;
9820
+ this.options = options;
9230
9821
  }
9231
9822
  binding;
9823
+ options;
9232
9824
  async checkLimit(key) {
9233
- const { success } = await this.binding.limit({ key });
9234
- return { isAllowed: success };
9825
+ try {
9826
+ const res = await this.binding.limit({ key });
9827
+ return {
9828
+ isAllowed: res.success,
9829
+ retryAfterSeconds: res.retryAfterSeconds ?? res.retryAfter
9830
+ };
9831
+ } catch (error) {
9832
+ console.warn(`Rate limiter binding error for key "${key}":`, error);
9833
+ return { isAllowed: !this.options.failClosed };
9834
+ }
9235
9835
  }
9236
9836
  };
9237
9837
 
@@ -9246,13 +9846,20 @@ var NoOpRateLimiter = class {
9246
9846
  var NO_OP = new NoOpRateLimiter();
9247
9847
  function buildDefaultRegistry(env) {
9248
9848
  const isDev = env.ENV === "development" && !(typeof globalThis.process !== "undefined" && globalThis.process.env?.VITEST);
9849
+ const isProd = env.ENV === "production";
9850
+ const getLimiter = (bindingName, binding, options) => {
9851
+ if (isProd && !binding) {
9852
+ throw new Error(`Missing rate limiter binding: ${bindingName} is required in production environment.`);
9853
+ }
9854
+ return !isDev && binding ? new CloudflareRateLimiter(binding, options) : NO_OP;
9855
+ };
9249
9856
  const limiters = {
9250
- login: !isDev && env.LOGIN_RATE_LIMITER ? new CloudflareRateLimiter(env.LOGIN_RATE_LIMITER) : NO_OP,
9251
- tokenRefresh: !isDev && env.REFRESH_RATE_LIMITER ? new CloudflareRateLimiter(env.REFRESH_RATE_LIMITER) : NO_OP,
9252
- forgotPassword: !isDev && env.FORGOT_PASSWORD_RATE_LIMITER ? new CloudflareRateLimiter(env.FORGOT_PASSWORD_RATE_LIMITER) : NO_OP,
9253
- resetPassword: !isDev && env.RESET_PASSWORD_RATE_LIMITER ? new CloudflareRateLimiter(env.RESET_PASSWORD_RATE_LIMITER) : NO_OP,
9254
- publicApiRead: !isDev && env.PUBLIC_READ_RATE_LIMITER ? new CloudflareRateLimiter(env.PUBLIC_READ_RATE_LIMITER) : NO_OP,
9255
- publicApiWrite: !isDev && env.PUBLIC_WRITE_RATE_LIMITER ? new CloudflareRateLimiter(env.PUBLIC_WRITE_RATE_LIMITER) : NO_OP
9857
+ login: getLimiter("LOGIN_RATE_LIMITER", env.LOGIN_RATE_LIMITER, { failClosed: true }),
9858
+ tokenRefresh: getLimiter("REFRESH_RATE_LIMITER", env.REFRESH_RATE_LIMITER, { failClosed: true }),
9859
+ forgotPassword: getLimiter("FORGOT_PASSWORD_RATE_LIMITER", env.FORGOT_PASSWORD_RATE_LIMITER, { failClosed: true }),
9860
+ resetPassword: getLimiter("RESET_PASSWORD_RATE_LIMITER", env.RESET_PASSWORD_RATE_LIMITER, { failClosed: true }),
9861
+ publicApiRead: getLimiter("PUBLIC_READ_RATE_LIMITER", env.PUBLIC_READ_RATE_LIMITER, { failClosed: false }),
9862
+ publicApiWrite: getLimiter("PUBLIC_WRITE_RATE_LIMITER", env.PUBLIC_WRITE_RATE_LIMITER, { failClosed: false })
9256
9863
  };
9257
9864
  return { getLimiter: (name) => limiters[name] };
9258
9865
  }
@@ -9268,7 +9875,7 @@ var rateLimiterMiddleware = (overrides) => {
9268
9875
  import { createMiddleware as createMiddleware7 } from "hono/factory";
9269
9876
  import { SystemClock as SystemClock4, SystemIdGenerator as SystemIdGenerator2 } from "@beechcms/core";
9270
9877
 
9271
- // src/shared/d1-activity-logger.ts
9878
+ // src/shared/services/activity-log/d1-activity-logger.ts
9272
9879
  var ACTIVITY_LOG_INSERT_SQL = `INSERT INTO activity_logs
9273
9880
  (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
9274
9881
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
@@ -9313,7 +9920,7 @@ var D1ActivityLogger = class {
9313
9920
  }
9314
9921
  };
9315
9922
 
9316
- // src/shared/background-notification-service.ts
9923
+ // src/shared/services/notification/background-notification-service.ts
9317
9924
  var DEFAULT_NOTIFICATION_TYPE = "info";
9318
9925
  var BackgroundNotificationService = class {
9319
9926
  constructor(notificationRepository, scheduleBackgroundTask) {
@@ -9343,7 +9950,7 @@ var BackgroundNotificationService = class {
9343
9950
  }
9344
9951
  };
9345
9952
 
9346
- // src/shared/qstash-notification-service.ts
9953
+ // src/shared/services/notification/qstash-notification-service.ts
9347
9954
  import { Client } from "@upstash/qstash";
9348
9955
  var QStashNotificationService = class {
9349
9956
  constructor(token, appUrl, scheduleBackgroundTask, qstashUrl) {
@@ -9412,6 +10019,82 @@ var observabilityMiddleware = (overrides) => {
9412
10019
  });
9413
10020
  };
9414
10021
 
10022
+ // src/middleware/queue.middleware.ts
10023
+ import { createMiddleware as createMiddleware8 } from "hono/factory";
10024
+ import { SystemClock as SystemClock5, SystemIdGenerator as SystemIdGenerator3 } from "@beechcms/core";
10025
+
10026
+ // src/shared/services/queue/cloudflare-queue-service.ts
10027
+ var CloudflareQueueService = class {
10028
+ constructor(queue) {
10029
+ this.queue = queue;
10030
+ }
10031
+ queue;
10032
+ async enqueue(name, payload) {
10033
+ try {
10034
+ await this.queue.send({ name, payload });
10035
+ } catch (error) {
10036
+ console.error(`CloudflareQueueService: failed to enqueue "${name}"`, error);
10037
+ }
10038
+ }
10039
+ };
10040
+
10041
+ // src/shared/services/queue/in-memory-queue-service.ts
10042
+ var InMemoryQueueService = class {
10043
+ constructor(jobs, context, scheduleBackgroundTask) {
10044
+ this.jobs = jobs;
10045
+ this.context = context;
10046
+ this.scheduleBackgroundTask = scheduleBackgroundTask;
10047
+ }
10048
+ jobs;
10049
+ context;
10050
+ scheduleBackgroundTask;
10051
+ async enqueue(name, payload) {
10052
+ const handler = this.jobs[name];
10053
+ if (!handler) {
10054
+ console.error(`InMemoryQueueService: no handler registered for "${name}"`);
10055
+ return;
10056
+ }
10057
+ const run = handler(payload, this.context).catch((error) => {
10058
+ console.error(`InMemoryQueueService: job "${name}" failed`, error);
10059
+ });
10060
+ if (this.scheduleBackgroundTask) {
10061
+ this.scheduleBackgroundTask(run);
10062
+ return;
10063
+ }
10064
+ await run;
10065
+ }
10066
+ };
10067
+
10068
+ // src/middleware/queue.middleware.ts
10069
+ var queueMiddleware = (jobs = {}, overrides) => {
10070
+ return createMiddleware8(async (context, next) => {
10071
+ let scheduleBackgroundTask;
10072
+ try {
10073
+ const executionContext = context.executionCtx;
10074
+ scheduleBackgroundTask = executionContext.waitUntil.bind(executionContext);
10075
+ } catch {
10076
+ scheduleBackgroundTask = void 0;
10077
+ }
10078
+ let queue;
10079
+ if (overrides?.queue) {
10080
+ queue = overrides.queue;
10081
+ } else if (context.env.QUEUE) {
10082
+ queue = new CloudflareQueueService(context.env.QUEUE);
10083
+ } else {
10084
+ const jobContext = {
10085
+ repository: context.get("repository"),
10086
+ bucket: context.get("bucket"),
10087
+ clock: SystemClock5,
10088
+ idGenerator: SystemIdGenerator3,
10089
+ env: context.env
10090
+ };
10091
+ queue = new InMemoryQueueService(jobs, jobContext, scheduleBackgroundTask);
10092
+ }
10093
+ context.set("queue", queue);
10094
+ await next();
10095
+ });
10096
+ };
10097
+
9415
10098
  // src/factory.ts
9416
10099
  var REFRESH_TOKEN_EXPIRY_DAYS = 7;
9417
10100
  var SECONDS_PER_DAY3 = 24 * 60 * 60;
@@ -9461,6 +10144,7 @@ function createBeechApp(config) {
9461
10144
  app.use("*", storageMiddleware({
9462
10145
  bucket: config.bucket
9463
10146
  }));
10147
+ app.use("*", queueMiddleware(config.jobs ?? {}));
9464
10148
  app.use("*", authProvidersMiddleware());
9465
10149
  app.use("*", rateLimiterMiddleware());
9466
10150
  app.use("*", observabilityMiddleware());
@@ -9481,7 +10165,7 @@ function createBeechApp(config) {
9481
10165
  try {
9482
10166
  const originUrl = new URL(origin);
9483
10167
  const requestUrl = new URL(context.req.url);
9484
- if (originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
10168
+ if (originUrl.protocol === requestUrl.protocol && originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
9485
10169
  return origin;
9486
10170
  }
9487
10171
  } catch {
@@ -9535,7 +10219,13 @@ function createBeechApp(config) {
9535
10219
  if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400);
9536
10220
  const clientIp = getClientIp(context.req);
9537
10221
  const loginRateLimit = await context.get("rateLimiters").getLimiter("login").checkLimit(clientIp);
9538
- if (!loginRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429);
10222
+ if (!loginRateLimit.isAllowed) {
10223
+ const headers = {};
10224
+ if (loginRateLimit.retryAfterSeconds !== void 0) {
10225
+ headers["Retry-After"] = String(loginRateLimit.retryAfterSeconds);
10226
+ }
10227
+ return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429, headers);
10228
+ }
9539
10229
  const user = await context.get("userRepository").findByEmail(email);
9540
10230
  const hashToCompare = user?.passwordHash ?? DUMMY_PASSWORD_HASH;
9541
10231
  const isValid = await verifyPassword(password, hashToCompare, context.get("hashProvider"));
@@ -9543,9 +10233,9 @@ function createBeechApp(config) {
9543
10233
  const accessToken = await context.get("tokenService").issue({ sub: user.id, email: user.email, name: user.name ?? void 0, surname: user.surname ?? void 0, role: user.role });
9544
10234
  const refreshToken = generateRefreshToken();
9545
10235
  const refreshTokenHash = await sha256hex7(refreshToken);
9546
- const nowSeconds = SystemClock5.nowSeconds();
10236
+ const nowSeconds = SystemClock6.nowSeconds();
9547
10237
  await context.get("sessionRepository").saveRefreshToken({
9548
- id: SystemIdGenerator3.uuid(),
10238
+ id: SystemIdGenerator4.uuid(),
9549
10239
  userId: user.id,
9550
10240
  tokenHash: refreshTokenHash,
9551
10241
  expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY3
@@ -9560,10 +10250,16 @@ function createBeechApp(config) {
9560
10250
  try {
9561
10251
  const refreshClientIp = getClientIp(context.req);
9562
10252
  const refreshRateLimit = await context.get("rateLimiters").getLimiter("tokenRefresh").checkLimit(refreshClientIp);
9563
- if (!refreshRateLimit.isAllowed) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429);
10253
+ if (!refreshRateLimit.isAllowed) {
10254
+ const headers = {};
10255
+ if (refreshRateLimit.retryAfterSeconds !== void 0) {
10256
+ headers["Retry-After"] = String(refreshRateLimit.retryAfterSeconds);
10257
+ }
10258
+ return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429, headers);
10259
+ }
9564
10260
  const refreshToken = getCookie(context, "refresh_token");
9565
10261
  if (!refreshToken) return context.json({ error: "Refresh token missing" }, 401);
9566
- const nowSeconds = SystemClock5.nowSeconds();
10262
+ const nowSeconds = SystemClock6.nowSeconds();
9567
10263
  const tokenHash = await sha256hex7(refreshToken);
9568
10264
  const activeSession = await context.get("sessionRepository").findActiveByHash(tokenHash, nowSeconds);
9569
10265
  if (!activeSession) return context.json({ error: "Invalid refresh token" }, 401);
@@ -9576,15 +10272,24 @@ function createBeechApp(config) {
9576
10272
  const newRefreshToken = generateRefreshToken();
9577
10273
  const newRefreshTokenHash = await sha256hex7(newRefreshToken);
9578
10274
  await context.get("sessionRepository").saveRefreshToken({
9579
- id: SystemIdGenerator3.uuid(),
10275
+ id: SystemIdGenerator4.uuid(),
9580
10276
  userId: user.id,
9581
10277
  tokenHash: newRefreshTokenHash,
9582
10278
  expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY3
9583
10279
  });
9584
- const revoked = await context.get("sessionRepository").revokeByHash(tokenHash, nowSeconds);
9585
- if (!revoked) return context.json({ error: "Invalid refresh token" }, 401);
9586
- setCookie(context, "refresh_token", newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)));
9587
- return context.json({ token: newAccessToken, expiresIn: "15m" }, 200);
10280
+ try {
10281
+ const revoked = await context.get("sessionRepository").revokeByHash(tokenHash, nowSeconds);
10282
+ if (!revoked) {
10283
+ await context.get("sessionRepository").revokeByHash(newRefreshTokenHash, nowSeconds);
10284
+ return context.json({ error: "Invalid refresh token" }, 401);
10285
+ }
10286
+ setCookie(context, "refresh_token", newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)));
10287
+ return context.json({ token: newAccessToken, expiresIn: "15m" }, 200);
10288
+ } catch (error) {
10289
+ await context.get("sessionRepository").revokeByHash(newRefreshTokenHash, nowSeconds).catch(() => {
10290
+ });
10291
+ throw error;
10292
+ }
9588
10293
  } catch (error) {
9589
10294
  return handleAuthError(context, error, "Refresh");
9590
10295
  }
@@ -9593,7 +10298,7 @@ function createBeechApp(config) {
9593
10298
  try {
9594
10299
  const refreshToken = getCookie(context, "refresh_token");
9595
10300
  if (refreshToken) {
9596
- const nowSeconds = SystemClock5.nowSeconds();
10301
+ const nowSeconds = SystemClock6.nowSeconds();
9597
10302
  const tokenHash = await sha256hex7(refreshToken);
9598
10303
  await context.get("sessionRepository").revokeByHash(tokenHash, nowSeconds);
9599
10304
  }
@@ -9626,7 +10331,7 @@ function createBeechApp(config) {
9626
10331
  apiPublic.route("/", publicRoutes);
9627
10332
  app.route("/api/v1/public", apiPublic);
9628
10333
  app.route("/api/webhooks", webhooksApp);
9629
- app.get("/api/media/:key", (context) => serveMediaHandler(context));
10334
+ app.get("/api/media/:key{.+}", (context) => serveMediaHandler(context));
9630
10335
  if (config.customRoutes) {
9631
10336
  const publicRouter = new Hono19();
9632
10337
  const protectedRouter = new Hono19();