@beechcms/api 0.6.0-preview.4 → 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 (121) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/chart-FUiy4I7R.js +39 -0
  4. package/assets/dashboard/assets/index-BlehzIfd.js +644 -0
  5. package/assets/dashboard/assets/index-Dve7eEqA.css +1 -0
  6. package/assets/dashboard/assets/{pie-chart-recharts-BMWpjFzf.js → pie-chart-recharts-iaYz4FeR.js} +1 -1
  7. package/assets/dashboard/assets/timeseries-chart-recharts-C8Lcylom.js +1 -0
  8. package/assets/dashboard/beechLogoDark.svg +48 -48
  9. package/assets/dashboard/index.html +17 -18
  10. package/assets/dashboard/noResult.svg +42 -42
  11. package/assets/dashboard/sol.svg +3 -3
  12. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  13. package/dist/auth/constants.d.ts +5 -5
  14. package/dist/features/automations/{automations.handler.d.ts → api/automations.handler.d.ts} +1 -1
  15. package/dist/features/automations/{automation-runner.utils.d.ts → engine/automation-runner.utils.d.ts} +1 -1
  16. package/dist/features/automations/{action-executors → executors}/edit-field.executor.d.ts +1 -1
  17. package/dist/features/automations/{action-executors → executors}/index.d.ts +1 -1
  18. package/dist/features/automations/{action-executors → executors}/send-mail.executor.d.ts +1 -1
  19. package/dist/features/automations/{action-executors → executors}/set-variable.executor.d.ts +1 -1
  20. package/dist/features/automations/{action-executors → executors}/webhook.executor.d.ts +1 -1
  21. package/dist/features/automations/{when-evaluator.d.ts → filters/when-evaluator.d.ts} +1 -1
  22. package/dist/features/automations/index.d.ts +7 -7
  23. package/dist/features/content/constants.d.ts +10 -10
  24. package/dist/features/content/handlers/kanban-move.d.ts +3 -0
  25. package/dist/features/content/handlers/kanban-position.d.ts +3 -0
  26. package/dist/features/content/handlers/view-config.d.ts +4 -0
  27. package/dist/{search.d.ts → features/search/search.d.ts} +1 -1
  28. package/dist/features/upload/index.d.ts +9 -0
  29. package/dist/{widget.d.ts → features/widget/widget.d.ts} +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.js +1057 -418
  32. package/dist/{middleware.d.ts → middleware/auth.middleware.d.ts} +1 -1
  33. package/dist/middleware/repository.middleware.d.ts +2 -1
  34. package/dist/public/access-policy.d.ts +1 -1
  35. package/dist/public/problem-details.d.ts +1 -0
  36. package/dist/public/public-errors.d.ts +4 -4
  37. package/dist/rate-limit/cloudflare-rate-limiter.d.ts +4 -1
  38. package/dist/rate-limit/in-memory-rate-limiter.d.ts +2 -1
  39. package/dist/shared/{schema-mutator.d1.d.ts → db/migrations/schema-mutator.d1.d.ts} +1 -0
  40. package/dist/shared/db/repositories/content.repository.d1.d.ts +259 -0
  41. package/dist/shared/db/repositories/kanban-position.repository.d1.d.ts +9 -0
  42. package/dist/shared/{seed-layout.repository.d1.d.ts → db/repositories/seed-layout.repository.d1.d.ts} +3 -1
  43. package/dist/shared/email/email.provider.d.ts +1 -0
  44. package/dist/{features → shared}/email/email.types.d.ts +1 -16
  45. package/dist/{features → shared}/email/providers/resend.d.ts +1 -2
  46. package/dist/{features → shared}/email/providers/smtp.d.ts +1 -2
  47. package/dist/shared/jobs/queue-consumer.d.ts +9 -0
  48. package/dist/shared/services/queue/cloudflare-queue-service.d.ts +11 -0
  49. package/dist/shared/services/queue/in-memory-queue-service.d.ts +16 -0
  50. package/dist/shared/storage/s3-bucket.d.ts +0 -1
  51. package/dist/shared/storage/upload.d.ts +7 -0
  52. package/dist/{media-utils.d.ts → shared/utils/media-utils.d.ts} +4 -2
  53. package/dist/types.d.ts +93 -2
  54. package/migrations/0000_v040_base.sql +265 -265
  55. package/migrations/0029_automations.sql +14 -14
  56. package/migrations/0030_test_seeds.sql +125 -125
  57. package/package.json +5 -4
  58. package/assets/dashboard/assets/chart-BwlCt-IA.js +0 -39
  59. package/assets/dashboard/assets/index-BcVN-DNy.js +0 -642
  60. package/assets/dashboard/assets/index-Cf1yRCqG.css +0 -1
  61. package/assets/dashboard/assets/timeseries-chart-recharts-8_GwAFR0.js +0 -1
  62. package/dist/features/email/email.provider.d.ts +0 -37
  63. package/dist/shared/content.repository.d1.d.ts +0 -68
  64. package/dist/upload.d.ts +0 -16
  65. package/dist/auth/{bcrypt-hash-provider.d.ts → providers/hash.provider.d.ts} +0 -0
  66. package/dist/auth/{jose-token-service.d.ts → providers/jwt-token.service.d.ts} +0 -0
  67. package/dist/auth/{login.d.ts → utils/login-helpers.d.ts} +0 -0
  68. package/dist/auth/{refresh.d.ts → utils/refresh-token.d.ts} +0 -0
  69. package/dist/features/automations/{automations.schema.d.ts → api/automations.schema.d.ts} +16 -16
  70. /package/dist/features/automations/{automation-runner.d.ts → engine/automation-runner.d.ts} +0 -0
  71. /package/dist/features/automations/{cron-runner.d.ts → engine/cron-runner.d.ts} +0 -0
  72. /package/dist/features/automations/{cron-runner.utils.d.ts → engine/cron-runner.utils.d.ts} +0 -0
  73. /package/dist/features/automations/{context-resolver.d.ts → evaluator/context-resolver.d.ts} +0 -0
  74. /package/dist/features/automations/{template-grammar.d.ts → evaluator/template-grammar.d.ts} +0 -0
  75. /package/dist/features/automations/{var-access-resolver.d.ts → evaluator/var-access-resolver.d.ts} +0 -0
  76. /package/dist/features/automations/{action-executors → executors}/create-entry.executor.d.ts +0 -0
  77. /package/dist/features/automations/{filter-translation.d.ts → filters/filter-translation.d.ts} +0 -0
  78. /package/dist/features/automations/{when-pushdown.d.ts → filters/when-pushdown.d.ts} +0 -0
  79. /package/dist/{search-utils.d.ts → features/search/search-utils.d.ts} +0 -0
  80. /package/dist/shared/{demo-data-sql.d.ts → db/migrations/demo-data-sql.d.ts} +0 -0
  81. /package/dist/shared/{automations.repository.d1.d.ts → db/repositories/automations.repository.d1.d.ts} +0 -0
  82. /package/dist/shared/{base.repository.d1.d.ts → db/repositories/base.repository.d1.d.ts} +0 -0
  83. /package/dist/shared/{d1-activity-log.repository.d.ts → db/repositories/d1-activity-log.repository.d.ts} +0 -0
  84. /package/dist/shared/{d1-analytics.repository.d.ts → db/repositories/d1-analytics.repository.d.ts} +0 -0
  85. /package/dist/shared/{d1-content-scan.repository.d.ts → db/repositories/d1-content-scan.repository.d.ts} +0 -0
  86. /package/dist/shared/{d1-notification.repository.d.ts → db/repositories/d1-notification.repository.d.ts} +0 -0
  87. /package/dist/shared/{d1-password-reset-token.repository.d.ts → db/repositories/d1-password-reset-token.repository.d.ts} +0 -0
  88. /package/dist/shared/{d1-search.repository.d.ts → db/repositories/d1-search.repository.d.ts} +0 -0
  89. /package/dist/shared/{d1-session.repository.d.ts → db/repositories/d1-session.repository.d.ts} +0 -0
  90. /package/dist/shared/{d1-setup-checklist.repository.d.ts → db/repositories/d1-setup-checklist.repository.d.ts} +0 -0
  91. /package/dist/shared/{d1-user.repository.d.ts → db/repositories/d1-user.repository.d.ts} +0 -0
  92. /package/dist/shared/{d1-widget.repository.d.ts → db/repositories/d1-widget.repository.d.ts} +0 -0
  93. /package/dist/shared/{dashboard-layout.repository.d1.d.ts → db/repositories/dashboard-layout.repository.d1.d.ts} +0 -0
  94. /package/dist/shared/{demo-data.repository.d1.d.ts → db/repositories/demo-data.repository.d1.d.ts} +0 -0
  95. /package/dist/shared/{idempotency.repository.d1.d.ts → db/repositories/idempotency.repository.d1.d.ts} +0 -0
  96. /package/dist/shared/{in-memory-seed.repository.d.ts → db/repositories/in-memory-seed.repository.d.ts} +0 -0
  97. /package/dist/shared/{media.repository.d1.d.ts → db/repositories/media.repository.d1.d.ts} +0 -0
  98. /package/dist/shared/{seed.repository.d1.d.ts → db/repositories/seed.repository.d1.d.ts} +0 -0
  99. /package/dist/shared/{site-settings.repository.d1.d.ts → db/repositories/site-settings.repository.d1.d.ts} +0 -0
  100. /package/dist/shared/{system-stats.repository.d1.d.ts → db/repositories/system-stats.repository.d1.d.ts} +0 -0
  101. /package/dist/{features → shared}/email/email.service.d.ts +0 -0
  102. /package/dist/{features → shared}/email/index.d.ts +0 -0
  103. /package/dist/{features → shared}/email/templates/automation-mail.d.ts +0 -0
  104. /package/dist/{features → shared}/email/templates/password-changed.d.ts +0 -0
  105. /package/dist/{features → shared}/email/templates/password-reset.d.ts +0 -0
  106. /package/dist/{features → shared}/email/templates/shell.d.ts +0 -0
  107. /package/dist/shared/{fts-sync.d.ts → jobs/fts-sync.d.ts} +0 -0
  108. /package/dist/shared/{apply-policies.d.ts → policies/apply-policies.d.ts} +0 -0
  109. /package/dist/shared/{d1-activity-logger.d.ts → services/activity-log/d1-activity-logger.d.ts} +0 -0
  110. /package/dist/shared/{in-memory-activity-logger.d.ts → services/activity-log/in-memory-activity-logger.d.ts} +0 -0
  111. /package/dist/shared/{seed-registry-cache.d.ts → services/cache/seed-registry-cache.d.ts} +0 -0
  112. /package/dist/shared/{fixed-clock.d.ts → services/clock/fixed-clock.d.ts} +0 -0
  113. /package/dist/shared/{sequential-id-generator.d.ts → services/id-generator/sequential-id-generator.d.ts} +0 -0
  114. /package/dist/shared/{background-notification-service.d.ts → services/notification/background-notification-service.d.ts} +0 -0
  115. /package/dist/shared/{in-memory-notification-service.d.ts → services/notification/in-memory-notification-service.d.ts} +0 -0
  116. /package/dist/shared/{qstash-notification-service.d.ts → services/notification/qstash-notification-service.d.ts} +0 -0
  117. /package/dist/shared/{execution-context-scheduler.d.ts → services/scheduler/execution-context-scheduler.d.ts} +0 -0
  118. /package/dist/shared/{content-utils.d.ts → utils/content-utils.d.ts} +0 -0
  119. /package/dist/shared/{query-utils.d.ts → utils/query-utils.d.ts} +0 -0
  120. /package/dist/shared/{request-utils.d.ts → utils/request-utils.d.ts} +0 -0
  121. /package/dist/shared/{storage-utils.d.ts → utils/storage-utils.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { cors } from "hono/cors";
4
4
  import { getCookie, setCookie, deleteCookie } from "hono/cookie";
5
5
  import { sha256hex as sha256hex7, SystemClock as SystemClock6, SystemIdGenerator as SystemIdGenerator4 } from "@beechcms/core";
6
6
 
7
- // src/shared/request-utils.ts
7
+ // src/shared/utils/request-utils.ts
8
8
  var CLOUDFLARE_CLIENT_IP_HEADER = "cf-connecting-ip";
9
9
  var UNKNOWN_IP = "unknown";
10
10
  function getClientIp(request) {
@@ -22,11 +22,12 @@ var AUTH_ERRORS = {
22
22
  RATE_LIMIT_EXCEEDED: "Too many requests"
23
23
  };
24
24
 
25
- // src/auth/login.ts
25
+ // src/auth/utils/login-helpers.ts
26
26
  var EMAIL_REGEX = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
27
27
  var MAX_EMAIL_LENGTH = 254;
28
28
  var MIN_PASSWORD_LENGTH = 8;
29
29
  var MAX_PASSWORD_LENGTH = 128;
30
+ var MAX_PASSWORD_BYTES = 72;
30
31
  var DUMMY_PASSWORD_HASH = "$2a$10$SbkRFOafACxVM2ahxerVDu3tSkCXWm29b62WdB.4WGG02Qjsfzni6";
31
32
  function parseLoginBody(body) {
32
33
  if (body === null || typeof body !== "object") return null;
@@ -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
  }
@@ -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,7 +3751,7 @@ 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
3670
3755
  import { signWebhookBody } from "@beechcms/core/webhook-crypto";
3671
3756
  var WEBHOOK_TIMEOUT_MS = 8e3;
3672
3757
  var warnedNoSecret = false;
@@ -3702,7 +3787,7 @@ async function executeWebhook(action, context, env) {
3702
3787
  }
3703
3788
  }
3704
3789
 
3705
- // src/features/automations/action-executors/send-mail.executor.ts
3790
+ // src/features/automations/executors/send-mail.executor.ts
3706
3791
  async function executeSendMail(action, context, env) {
3707
3792
  const missingFields = [];
3708
3793
  const logMissing = (field) => missingFields.push(field);
@@ -3735,7 +3820,7 @@ async function executeSendMail(action, context, env) {
3735
3820
  }
3736
3821
  }
3737
3822
 
3738
- // src/features/automations/action-executors/edit-field.executor.ts
3823
+ // src/features/automations/executors/edit-field.executor.ts
3739
3824
  async function executeEditField(action, entry, context, repository, seed) {
3740
3825
  const id = entry.id;
3741
3826
  if (typeof id !== "string") {
@@ -3745,7 +3830,7 @@ async function executeEditField(action, entry, context, repository, seed) {
3745
3830
  await repository.update(seed, id, { [action.field]: resolved });
3746
3831
  }
3747
3832
 
3748
- // src/features/automations/action-executors/create-entry.executor.ts
3833
+ // src/features/automations/executors/create-entry.executor.ts
3749
3834
  async function executeCreateEntry(action, entry, repository, getSeed, idGenerator) {
3750
3835
  const targetSeed = getSeed(action.seed_slug);
3751
3836
  if (!targetSeed) throw new Error(`create_entry: unknown seed ${action.seed_slug}`);
@@ -3757,7 +3842,7 @@ async function executeCreateEntry(action, entry, repository, getSeed, idGenerato
3757
3842
  await repository.create(targetSeed, newId, newId, "draft", payload);
3758
3843
  }
3759
3844
 
3760
- // src/features/automations/filter-translation.ts
3845
+ // src/features/automations/filters/filter-translation.ts
3761
3846
  var SYSTEM_COLUMNS = /* @__PURE__ */ new Set(["id", "slug", "status", "created_at", "updated_at"]);
3762
3847
  function mapBranchTypeToFilterType(type) {
3763
3848
  switch (type) {
@@ -3795,7 +3880,7 @@ function conditionToFilterGroup(c, seed) {
3795
3880
  };
3796
3881
  }
3797
3882
 
3798
- // src/features/automations/action-executors/set-variable.executor.ts
3883
+ // src/features/automations/executors/set-variable.executor.ts
3799
3884
  async function executeSetVariable(action, ctx) {
3800
3885
  const seedSlug = action.seed_slug ?? ctx.seed.slug;
3801
3886
  const targetSeed = ctx.getSeed(seedSlug);
@@ -3908,7 +3993,7 @@ function calculateGeneralStats(seed, items) {
3908
3993
  return { count: items.length, sum, avg, min, max, pluck };
3909
3994
  }
3910
3995
 
3911
- // src/features/automations/action-executors/index.ts
3996
+ // src/features/automations/executors/index.ts
3912
3997
  async function executeAction(action, ctx) {
3913
3998
  switch (action.type) {
3914
3999
  case "set_variable":
@@ -3928,7 +4013,7 @@ async function executeAction(action, ctx) {
3928
4013
  }
3929
4014
  }
3930
4015
 
3931
- // src/features/automations/when-evaluator.ts
4016
+ // src/features/automations/filters/when-evaluator.ts
3932
4017
  function evaluateWhen(node, context) {
3933
4018
  if (!node) return true;
3934
4019
  if (node.kind === "predicate") return evalPredicate(node, context);
@@ -4008,7 +4093,7 @@ function coerceEqual(a, b) {
4008
4093
  return a === b;
4009
4094
  }
4010
4095
 
4011
- // src/features/automations/var-access-resolver.ts
4096
+ // src/features/automations/evaluator/var-access-resolver.ts
4012
4097
  function toNumeric(v) {
4013
4098
  const n = Number(v);
4014
4099
  if (Number.isFinite(n)) return n;
@@ -4111,7 +4196,7 @@ function resolveVarAccess(parsed, variables, onMissing) {
4111
4196
  return current;
4112
4197
  }
4113
4198
 
4114
- // src/features/automations/automation-runner.ts
4199
+ // src/features/automations/engine/automation-runner.ts
4115
4200
  function withVariables(base, variables) {
4116
4201
  return {
4117
4202
  triggerEntry: base.triggerEntry,
@@ -4164,10 +4249,10 @@ var AutomationRunner = class {
4164
4249
  }
4165
4250
  };
4166
4251
 
4167
- // src/features/automations/automations.handler.ts
4168
- import { Hono as Hono13 } from "hono";
4252
+ // src/features/automations/api/automations.handler.ts
4253
+ import { Hono as Hono12 } from "hono";
4169
4254
 
4170
- // src/features/automations/automations.schema.ts
4255
+ // src/features/automations/api/automations.schema.ts
4171
4256
  import { z as z2 } from "zod";
4172
4257
  import { isPrivateHost } from "@beechcms/core";
4173
4258
  var whenOperandSchema = z2.union([
@@ -4298,8 +4383,8 @@ var toggleAutomationSchema = z2.object({
4298
4383
  enabled: z2.boolean()
4299
4384
  });
4300
4385
 
4301
- // src/features/automations/automations.handler.ts
4302
- var automationsApp = new Hono13();
4386
+ // src/features/automations/api/automations.handler.ts
4387
+ var automationsApp = new Hono12();
4303
4388
  automationsApp.get("/", async (context) => {
4304
4389
  const seedSlug = context.req.query("seed");
4305
4390
  if (!seedSlug) {
@@ -4445,7 +4530,7 @@ automationsApp.delete("/:id", async (context) => {
4445
4530
  });
4446
4531
 
4447
4532
  // src/features/stats/stats.handler.ts
4448
- import { Hono as Hono14 } from "hono";
4533
+ import { Hono as Hono13 } from "hono";
4449
4534
  import { SystemClock } from "@beechcms/core";
4450
4535
  var DATABASE_ERROR = "Database error";
4451
4536
  var INTERNAL_SERVER_ERROR = "Internal Server Error";
@@ -4467,7 +4552,7 @@ function getMimeType(extension) {
4467
4552
  const type = extension === "jpg" ? "jpeg" : extension || "jpeg";
4468
4553
  return `image/${type}`;
4469
4554
  }
4470
- var statsApp = new Hono14();
4555
+ var statsApp = new Hono13();
4471
4556
  statsApp.get("/stats/media-library", async (context) => {
4472
4557
  try {
4473
4558
  const mediaRepository = context.get("mediaRepository");
@@ -4763,7 +4848,7 @@ statsApp.post("/stats/storage/sync", async (context) => {
4763
4848
  });
4764
4849
 
4765
4850
  // src/features/backrefs/backrefs.handler.ts
4766
- import { Hono as Hono15 } from "hono";
4851
+ import { Hono as Hono14 } from "hono";
4767
4852
  import { resolvePolicies as resolvePolicies6 } from "@beechcms/core";
4768
4853
 
4769
4854
  // src/features/backrefs/d1-backref.repository.ts
@@ -4815,7 +4900,7 @@ var D1BackrefRepository = class {
4815
4900
  var PREVIEW_LIMIT = 3;
4816
4901
  var DEFAULT_PAGE_LIMIT = 20;
4817
4902
  var MAX_PAGE_LIMIT = 100;
4818
- var backrefsApp = new Hono15();
4903
+ var backrefsApp = new Hono14();
4819
4904
  backrefsApp.get("/:targetSlug/:targetId/backrefs", async (c) => {
4820
4905
  const targetSlug = c.req.param("targetSlug");
4821
4906
  const targetId = c.req.param("targetId");
@@ -4925,61 +5010,234 @@ function filterVisibility(items, sourceSeed) {
4925
5010
  if (visibility === "hidden") {
4926
5011
  return items.map((item) => ({ ...item, displayName: null }));
4927
5012
  }
4928
- if (visibility === "masked") {
4929
- return items.map((item) => ({
4930
- ...item,
4931
- displayName: item.displayName ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : null
4932
- }));
5013
+ if (visibility === "masked") {
5014
+ return items.map((item) => ({
5015
+ ...item,
5016
+ displayName: item.displayName ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : null
5017
+ }));
5018
+ }
5019
+ return items;
5020
+ }
5021
+
5022
+ // src/features/webhooks/index.ts
5023
+ import { Hono as Hono15 } from "hono";
5024
+ import { Receiver } from "@upstash/qstash";
5025
+ var webhooksApp = new Hono15();
5026
+ webhooksApp.post("/qstash", async (context) => {
5027
+ const currentSigningKey = context.env.QSTASH_CURRENT_SIGNING_KEY;
5028
+ const nextSigningKey = context.env.QSTASH_NEXT_SIGNING_KEY;
5029
+ if (!currentSigningKey || !nextSigningKey) {
5030
+ console.error("QStash webhook called but signing keys are not configured");
5031
+ return context.text("Configuration error", 500);
5032
+ }
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);
4933
5140
  }
4934
- return items;
4935
- }
4936
-
4937
- // src/features/webhooks/index.ts
4938
- import { Hono as Hono16 } from "hono";
4939
- import { Receiver } from "@upstash/qstash";
4940
- var webhooksApp = new Hono16();
4941
- webhooksApp.post("/qstash", async (context) => {
4942
- const currentSigningKey = context.env.QSTASH_CURRENT_SIGNING_KEY;
4943
- const nextSigningKey = context.env.QSTASH_NEXT_SIGNING_KEY;
4944
- if (!currentSigningKey || !nextSigningKey) {
4945
- console.error("QStash webhook called but signing keys are not configured");
4946
- return context.text("Configuration error", 500);
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);
4947
5149
  }
4948
- const receiver = new Receiver({
4949
- currentSigningKey,
4950
- nextSigningKey
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
4951
5160
  });
4952
- const body = await context.req.text();
4953
- const signature = context.req.header("Upstash-Signature");
4954
- if (!signature) {
4955
- return context.text("Missing signature", 401);
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);
4956
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 });
4957
5221
  try {
4958
- const isValid = await receiver.verify({
4959
- signature,
4960
- body
4961
- });
4962
- if (!isValid) {
4963
- 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);
4964
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 });
4965
5236
  } catch (err) {
4966
- console.error("QStash signature verification failed", err);
4967
- return context.text("Invalid signature", 401);
4968
- }
4969
- try {
4970
- const input = JSON.parse(body);
4971
- const notificationRepository = context.get("notificationRepository");
4972
- await notificationRepository.create({
4973
- title: input.title,
4974
- message: input.message,
4975
- type: input.type ?? "info"
4976
- });
4977
- return context.text("OK", 200);
4978
- } catch (error) {
4979
- console.error("Failed to process QStash webhook payload", error);
4980
- return context.text("Internal server error", 500);
5237
+ console.error(`[serveMediaHandler] Error serving file ${key}:`, err);
5238
+ return new Response("Internal error", { status: 500 });
4981
5239
  }
4982
- });
5240
+ }
4983
5241
 
4984
5242
  // src/public/public-errors.ts
4985
5243
  var PUBLIC_ERRORS = {
@@ -5040,11 +5298,16 @@ function publicRateLimitMiddleware() {
5040
5298
  const key = `${getClientIp(c.req)}:${seed}:${limiterName}`;
5041
5299
  const result = await c.get("rateLimiters").getLimiter(limiterName).checkLimit(key);
5042
5300
  if (!result.isAllowed) {
5301
+ const headers = {};
5302
+ if (result.retryAfterSeconds !== void 0) {
5303
+ headers["Retry-After"] = String(result.retryAfterSeconds);
5304
+ }
5043
5305
  return publicProblem(c, {
5044
5306
  type: "rate-limit-exceeded",
5045
5307
  title: "Too Many Requests",
5046
5308
  status: 429,
5047
- detail: "Too many requests"
5309
+ detail: "Too many requests",
5310
+ headers
5048
5311
  });
5049
5312
  }
5050
5313
  await next();
@@ -5363,10 +5626,10 @@ import { isValidContentStatus as isValidContentStatus3, SlugConflictError as Slu
5363
5626
  import { slugify as slugify3, generateEntrySlug } from "@beechcms/core";
5364
5627
 
5365
5628
  // src/public/sanitize.ts
5366
- import { validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload5 } from "@beechcms/core";
5629
+ import { validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload6 } from "@beechcms/core";
5367
5630
  function sanitizePublicPayload(seed, payload, options = {}) {
5368
5631
  const operation = options.operation ?? "create";
5369
- const result = validateAndSanitizeSeedPayload5(seed, payload, {
5632
+ const result = validateAndSanitizeSeedPayload6(seed, payload, {
5370
5633
  allowNull: options.allowNull ?? false,
5371
5634
  operation,
5372
5635
  requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
@@ -5678,10 +5941,10 @@ publicApp.post("/:seed/add", publicAddHandler);
5678
5941
  publicApp.put("/:seed/edit/:id", publicEditHandler);
5679
5942
  var publicRoutes = publicApp;
5680
5943
 
5681
- // src/search.ts
5944
+ // src/features/search/search.ts
5682
5945
  import { Hono as Hono18 } from "hono";
5683
5946
 
5684
- // src/search-utils.ts
5947
+ // src/features/search/search-utils.ts
5685
5948
  function encodeCursor(rank, entryId) {
5686
5949
  return btoa(`${rank}:${entryId}`);
5687
5950
  }
@@ -5786,7 +6049,7 @@ function mapSearchResultRow(row) {
5786
6049
  };
5787
6050
  }
5788
6051
 
5789
- // src/search.ts
6052
+ // src/features/search/search.ts
5790
6053
  var searchRouter = new Hono18();
5791
6054
  searchRouter.use("*", authMiddleware());
5792
6055
  searchRouter.get("/", async (c) => {
@@ -5827,19 +6090,19 @@ searchRouter.get("/", async (c) => {
5827
6090
  // src/middleware/repository.middleware.ts
5828
6091
  import { createMiddleware as createMiddleware2 } from "hono/factory";
5829
6092
 
5830
- // src/shared/content.repository.d1.ts
6093
+ // src/shared/db/repositories/content.repository.d1.ts
5831
6094
  import {
5832
6095
  EntryNotFoundError as EntryNotFoundError7,
5833
6096
  RepositoryError as RepositoryError2,
5834
- SlugConflictError as SlugConflictError3,
6097
+ SlugConflictError as SlugConflictError4,
5835
6098
  RelationTargetNotFoundError as RelationTargetNotFoundError2,
5836
6099
  buildSelectQuery,
5837
6100
  deserializeFromDb as deserializeFromDb2,
5838
6101
  serializeForDb
5839
6102
  } from "@beechcms/core";
5840
6103
 
5841
- // src/shared/base.repository.d1.ts
5842
- import { RepositoryError } from "@beechcms/core";
6104
+ // src/shared/db/repositories/base.repository.d1.ts
6105
+ import { RepositoryError, SlugConflictError as SlugConflictError3 } from "@beechcms/core";
5843
6106
  var BaseD1Repository = class {
5844
6107
  constructor(database) {
5845
6108
  this.database = database;
@@ -5857,11 +6120,14 @@ var BaseD1Repository = class {
5857
6120
  */
5858
6121
  mapError(error, context) {
5859
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
+ }
5860
6126
  return new RepositoryError(`${context}: ${message}`, error);
5861
6127
  }
5862
6128
  };
5863
6129
 
5864
- // src/shared/content.repository.d1.ts
6130
+ // src/shared/db/repositories/content.repository.d1.ts
5865
6131
  function multiRelBranches(seed) {
5866
6132
  return seed.branches.filter((b) => b.type === "relation" && b.multiple === true);
5867
6133
  }
@@ -5880,6 +6146,7 @@ var D1ContentRepository = class extends BaseD1Repository {
5880
6146
  this.hooks = hooks;
5881
6147
  }
5882
6148
  hooks;
6149
+ /** Assembles the {@link HookContext} passed to every lifecycle hook invocation. */
5883
6150
  hookCtx(seed, actor) {
5884
6151
  return { seed, repository: this, actor, db: this.database };
5885
6152
  }
@@ -5901,10 +6168,15 @@ var D1ContentRepository = class extends BaseD1Repository {
5901
6168
  data[branch.alias] = deserializeFromDb2(branch, row[branch.alias]);
5902
6169
  }
5903
6170
  }
6171
+ if (row.position !== void 0) data.position = row.position;
5904
6172
  return data;
5905
6173
  }
5906
6174
  /**
5907
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.
5908
6180
  */
5909
6181
  async attachMultiRelations(seed, entryId, data) {
5910
6182
  const branches = multiRelBranches(seed);
@@ -5918,51 +6190,85 @@ var D1ContentRepository = class extends BaseD1Repository {
5918
6190
  }
5919
6191
  }
5920
6192
  /**
5921
- * Fetches multi-relation arrays for a list of entries and attaches them.
5922
- * One query per multi-relation branch (O(R) queries, R = branch count).
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.
5923
6200
  */
5924
- async attachMultiRelationsMany(seed, entries) {
5925
- const branches = multiRelBranches(seed);
5926
- if (branches.length === 0 || entries.length === 0) return;
5927
- const ids = entries.map((e) => e.id);
5928
- const placeholders = ids.map(() => "?").join(", ");
5929
- const stmts = branches.map(
6201
+ multiRelStatements(seed, branches, idQuery) {
6202
+ return branches.map(
5930
6203
  (b) => this.database.prepare(
5931
- `SELECT parent_id, target_id FROM ${jTable(seed.slug, b.alias)} WHERE parent_id IN (${placeholders}) ORDER BY parent_id, position ASC`
5932
- ).bind(...ids)
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)
5933
6206
  );
5934
- 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) {
5935
6215
  for (let i = 0; i < branches.length; i++) {
5936
6216
  const branch = branches[i];
5937
6217
  const byParent = /* @__PURE__ */ new Map();
5938
- for (const row of results[i].results ?? []) {
6218
+ for (const row of relResults[i].results ?? []) {
5939
6219
  const r = row;
5940
6220
  const pid = r.parent_id;
5941
- if (!byParent.has(pid)) byParent.set(pid, []);
5942
- 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);
5943
6227
  }
5944
6228
  for (const entry of entries) {
5945
6229
  entry[branch.alias] = byParent.get(entry.id) ?? [];
5946
6230
  }
5947
6231
  }
5948
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
+ */
5949
6245
  async findMany(seed, options) {
5950
6246
  try {
5951
6247
  const { sql, bindings } = buildSelectQuery(seed, options);
5952
- const countSql = sql.replace(/SELECT .* FROM/, "SELECT COUNT(*) as total FROM").replace(/ ORDER BY .*$/, "").replace(/ LIMIT \? OFFSET \?$/, "");
5953
- const countBindings = bindings.slice(0, bindings.length - (options.pagination ? 2 : 0));
5954
- const [batchResults, totalCountResult] = await this.database.batch([
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([
5955
6255
  this.database.prepare(sql).bind(...bindings),
5956
- this.database.prepare(countSql).bind(...countBindings)
6256
+ this.database.prepare(countSql).bind(...countBindings),
6257
+ ...relStmts
5957
6258
  ]);
5958
6259
  const contentEntries = (batchResults.results || []).map((row) => this.rowToData(seed, row));
5959
6260
  const totalEntriesCount = totalCountResult.results?.[0]?.total || 0;
5960
- await this.attachMultiRelationsMany(seed, contentEntries);
6261
+ this.attachMultiRelationRows(branches, relResults, contentEntries);
5961
6262
  return { items: contentEntries, total: totalEntriesCount };
5962
6263
  } catch (error) {
5963
6264
  throw this.mapError(error, `findMany(${seed.slug})`);
5964
6265
  }
5965
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
+ */
5966
6272
  async findById(seed, id) {
5967
6273
  try {
5968
6274
  const tableName = this.getTableName(seed.slug);
@@ -5978,6 +6284,11 @@ var D1ContentRepository = class extends BaseD1Repository {
5978
6284
  throw this.mapError(error, `findById(${seed.slug}, ${id})`);
5979
6285
  }
5980
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
+ */
5981
6292
  async findBySlug(seed, slug) {
5982
6293
  try {
5983
6294
  const tableName = this.getTableName(seed.slug);
@@ -5993,6 +6304,11 @@ var D1ContentRepository = class extends BaseD1Repository {
5993
6304
  throw this.mapError(error, `findBySlug(${seed.slug}, ${slug})`);
5994
6305
  }
5995
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
+ */
5996
6312
  async getFacets(seed) {
5997
6313
  try {
5998
6314
  const tableName = this.getTableName(seed.slug);
@@ -6012,6 +6328,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6012
6328
  throw this.mapError(error, `getFacets(${seed.slug})`);
6013
6329
  }
6014
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
+ */
6015
6335
  async existsSlug(seed, slug, excludeId) {
6016
6336
  try {
6017
6337
  const tableName = this.getTableName(seed.slug);
@@ -6027,6 +6347,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6027
6347
  throw this.mapError(error, `existsSlug(${seed.slug}, ${slug})`);
6028
6348
  }
6029
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
+ */
6030
6354
  buildCreateMainStmt(seed, id, slug, status, data) {
6031
6355
  const tableName = this.getTableName(seed.slug);
6032
6356
  const columnNames = ["id", "slug", "status"];
@@ -6044,6 +6368,15 @@ var D1ContentRepository = class extends BaseD1Repository {
6044
6368
  const mainSql = `INSERT INTO ${tableName} (${columnNames.join(", ")}) VALUES (${placeholders.join(", ")})`;
6045
6369
  return this.database.prepare(mainSql).bind(...queryBindings);
6046
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
+ */
6047
6380
  buildUpdateMainStmt(seed, id, data, status) {
6048
6381
  const tableName = this.getTableName(seed.slug);
6049
6382
  const updateClauses = [];
@@ -6054,6 +6387,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6054
6387
  updateClauses.push("status = ?");
6055
6388
  queryBindings.push(status);
6056
6389
  }
6390
+ if (data.slug) {
6391
+ updateClauses.push("slug = ?");
6392
+ queryBindings.push(data.slug);
6393
+ }
6057
6394
  for (const branch of seed.branches) {
6058
6395
  if (mRelAliases.has(branch.alias)) continue;
6059
6396
  if (Object.hasOwn(data, branch.alias)) {
@@ -6073,18 +6410,28 @@ var D1ContentRepository = class extends BaseD1Repository {
6073
6410
  junctionUpdates
6074
6411
  };
6075
6412
  }
6413
+ /** Builds one `INSERT` per target id into a live multi-relation junction table, preserving array order via `position`. */
6076
6414
  buildJunctionInserts(seedSlug, parentId, branchAlias, targetIds) {
6077
6415
  const jt = jTable(seedSlug, branchAlias);
6078
6416
  return targetIds.map(
6079
6417
  (targetId, i) => this.database.prepare(`INSERT INTO ${jt} (parent_id, target_id, position) VALUES (?, ?, ?)`).bind(parentId, targetId, i)
6080
6418
  );
6081
6419
  }
6420
+ /** Builds one `INSERT` per target id into a draft multi-relation junction table, preserving array order via `position`. */
6082
6421
  buildDraftJunctionInserts(seedSlug, entryId, branchAlias, targetIds) {
6083
6422
  const jdt = jDraftTable(seedSlug, branchAlias);
6084
6423
  return targetIds.map(
6085
6424
  (targetId, i) => this.database.prepare(`INSERT INTO ${jdt} (entry_id, target_id, position) VALUES (?, ?, ?)`).bind(entryId, targetId, i)
6086
6425
  );
6087
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
+ */
6088
6435
  async validatePublishDraftRelations(seed, draftRow, entryId) {
6089
6436
  for (const branch of singleRelBranches(seed)) {
6090
6437
  const value = draftRow[branch.alias];
@@ -6118,6 +6465,12 @@ var D1ContentRepository = class extends BaseD1Repository {
6118
6465
  }
6119
6466
  return { mRelBranches, mRelDraftIds };
6120
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
+ */
6121
6474
  getBulkArrayUpdateStmts(seedSlug, id, alias, update) {
6122
6475
  const jt = jTable(seedSlug, alias);
6123
6476
  const stmts = [];
@@ -6143,16 +6496,23 @@ var D1ContentRepository = class extends BaseD1Repository {
6143
6496
  }
6144
6497
  return stmts;
6145
6498
  }
6146
- 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) {
6147
6505
  const stmts = [];
6148
6506
  const setClauses = ["updated_at = (unixepoch())"];
6149
6507
  const setBindings = [];
6150
6508
  for (const [alias, update] of Object.entries(fields)) {
6151
6509
  if (update.kind === "set") {
6152
- setClauses.unshift(`${alias} = ?`);
6153
- 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);
6154
6514
  } else {
6155
- stmts.push(...this.getBulkArrayUpdateStmts(seedSlug, id, alias, update));
6515
+ stmts.push(...this.getBulkArrayUpdateStmts(seed.slug, id, alias, update));
6156
6516
  }
6157
6517
  }
6158
6518
  stmts.unshift(
@@ -6163,12 +6523,17 @@ var D1ContentRepository = class extends BaseD1Repository {
6163
6523
  throw new Error("not-found");
6164
6524
  }
6165
6525
  }
6166
- 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) {
6167
6532
  let updated = 0;
6168
6533
  const failed = [];
6169
6534
  for (const id of chunk) {
6170
6535
  try {
6171
- await this.processBulkUpdateSingle(seedSlug, id, fields, tableName);
6536
+ await this.processBulkUpdateSingle(seed, id, fields, tableName);
6172
6537
  updated++;
6173
6538
  } catch (err) {
6174
6539
  const msg = err instanceof Error ? err.message : String(err);
@@ -6182,6 +6547,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6182
6547
  }
6183
6548
  return { updated, failed };
6184
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
+ */
6185
6558
  async create(seed, id, slug, status, data, options) {
6186
6559
  let payload = data;
6187
6560
  if (this.hooks?.beforeCreate) {
@@ -6190,7 +6563,7 @@ var D1ContentRepository = class extends BaseD1Repository {
6190
6563
  }
6191
6564
  try {
6192
6565
  if (await this.existsSlug(seed, slug)) {
6193
- throw new SlugConflictError3(`Slug "${slug}" already exists for ${seed.slug}`);
6566
+ throw new SlugConflictError4(`Slug "${slug}" already exists for ${seed.slug}`);
6194
6567
  }
6195
6568
  const batchStmts = [
6196
6569
  this.buildCreateMainStmt(seed, id, slug, status, payload)
@@ -6206,7 +6579,7 @@ var D1ContentRepository = class extends BaseD1Repository {
6206
6579
  await this.database.batch(batchStmts);
6207
6580
  }
6208
6581
  } catch (error) {
6209
- if (error instanceof SlugConflictError3) throw error;
6582
+ if (error instanceof SlugConflictError4) throw error;
6210
6583
  throw this.mapError(error, `create(${seed.slug})`);
6211
6584
  }
6212
6585
  if (this.hooks?.afterCreate) {
@@ -6214,6 +6587,15 @@ var D1ContentRepository = class extends BaseD1Repository {
6214
6587
  await this.hooks.afterCreate(entry, this.hookCtx(seed, options?.actor));
6215
6588
  }
6216
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
+ */
6217
6599
  async update(seed, id, data, status, options) {
6218
6600
  let payload = data;
6219
6601
  if (this.hooks?.beforeUpdate) {
@@ -6251,6 +6633,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6251
6633
  await this.hooks.afterUpdate(entry, this.hookCtx(seed, options?.actor));
6252
6634
  }
6253
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
+ */
6254
6644
  async delete(seed, id, options) {
6255
6645
  if (this.hooks?.beforeDelete) {
6256
6646
  await this.hooks.beforeDelete(id, this.hookCtx(seed, options?.actor));
@@ -6273,6 +6663,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6273
6663
  }
6274
6664
  return { row };
6275
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
+ */
6276
6674
  async mutateField(seed, id, fieldName, operation, options) {
6277
6675
  try {
6278
6676
  const branch = seed.branches.find((b) => b.alias === fieldName);
@@ -6302,6 +6700,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6302
6700
  throw this.mapError(error, `mutateField(${seed.slug}, ${id}, ${fieldName})`);
6303
6701
  }
6304
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
+ */
6305
6711
  async runBatch(operations) {
6306
6712
  if (operations.length === 0) return;
6307
6713
  try {
@@ -6350,6 +6756,11 @@ var D1ContentRepository = class extends BaseD1Repository {
6350
6756
  throw this.mapError(error, "runBatch");
6351
6757
  }
6352
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
+ */
6353
6764
  async saveDraft(seed, entryId, data) {
6354
6765
  try {
6355
6766
  if (!seed.allowDrafts) {
@@ -6358,6 +6769,21 @@ var D1ContentRepository = class extends BaseD1Repository {
6358
6769
  const draftTableName = this.getTableName(seed.slug, true);
6359
6770
  const mRelBranches = multiRelBranches(seed);
6360
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);
6361
6787
  const columnNames = ["entry_id"];
6362
6788
  const placeholders = ["?"];
6363
6789
  const queryBindings = [entryId];
@@ -6372,6 +6798,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6372
6798
  updateClauses.push(`${branch.alias} = EXCLUDED.${branch.alias}`);
6373
6799
  }
6374
6800
  }
6801
+ columnNames.push("_touched_fields");
6802
+ placeholders.push("?");
6803
+ queryBindings.push(JSON.stringify(updatedTouchedFields));
6804
+ updateClauses.push(`_touched_fields = EXCLUDED._touched_fields`);
6375
6805
  updateClauses.push("updated_at = (unixepoch())");
6376
6806
  const sql = `
6377
6807
  INSERT INTO ${draftTableName} (${columnNames.join(", ")})
@@ -6403,6 +6833,10 @@ var D1ContentRepository = class extends BaseD1Repository {
6403
6833
  throw this.mapError(error, `saveDraft(${seed.slug}, ${entryId})`);
6404
6834
  }
6405
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
+ */
6406
6840
  async getDraft(seed, entryId) {
6407
6841
  try {
6408
6842
  if (!seed.allowDrafts) return null;
@@ -6411,10 +6845,20 @@ var D1ContentRepository = class extends BaseD1Repository {
6411
6845
  if (!draftRow) return null;
6412
6846
  const mRelBranches = multiRelBranches(seed);
6413
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);
6414
6856
  const draftData = {};
6415
6857
  for (const branch of seed.branches) {
6416
6858
  if (mRelAliases.has(branch.alias)) continue;
6417
- 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) {
6418
6862
  draftData[branch.alias] = deserializeFromDb2(branch, draftRow[branch.alias]);
6419
6863
  }
6420
6864
  }
@@ -6424,9 +6868,12 @@ var D1ContentRepository = class extends BaseD1Repository {
6424
6868
  );
6425
6869
  const results = await this.database.batch(stmts);
6426
6870
  for (let i = 0; i < mRelBranches.length; i++) {
6871
+ const alias = mRelBranches[i].alias;
6427
6872
  const ids = (results[i].results ?? []).map((r) => r.target_id);
6428
- if (ids.length > 0) {
6429
- draftData[mRelBranches[i].alias] = ids;
6873
+ if (touchedSet.has(alias)) {
6874
+ draftData[alias] = ids;
6875
+ } else if (ids.length > 0) {
6876
+ draftData[alias] = ids;
6430
6877
  }
6431
6878
  }
6432
6879
  }
@@ -6435,6 +6882,7 @@ var D1ContentRepository = class extends BaseD1Repository {
6435
6882
  throw this.mapError(error, `getDraft(${seed.slug}, ${entryId})`);
6436
6883
  }
6437
6884
  }
6885
+ /** Checks whether a draft row exists for `entryId`. Always `false` if `seed.allowDrafts` is false. */
6438
6886
  async hasDraft(seed, entryId) {
6439
6887
  try {
6440
6888
  if (!seed.allowDrafts) return false;
@@ -6445,6 +6893,15 @@ var D1ContentRepository = class extends BaseD1Repository {
6445
6893
  throw this.mapError(error, `hasDraft(${seed.slug}, ${entryId})`);
6446
6894
  }
6447
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
+ */
6448
6905
  async publishDraft(seed, entryId) {
6449
6906
  try {
6450
6907
  if (!seed.allowDrafts) return;
@@ -6454,33 +6911,56 @@ var D1ContentRepository = class extends BaseD1Repository {
6454
6911
  if (!draftRow) {
6455
6912
  throw new EntryNotFoundError7(`No draft found for ${entryId} in ${seed.slug}`);
6456
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);
6457
6922
  const { mRelBranches, mRelDraftIds } = await this.validatePublishDraftRelations(seed, draftRow, entryId);
6458
6923
  const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
6459
6924
  const updateClauses = [];
6460
6925
  const queryBindings = [];
6461
6926
  for (const branch of seed.branches) {
6462
6927
  if (mRelAliases.has(branch.alias)) continue;
6463
- 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) {
6464
6932
  updateClauses.push(`${branch.alias} = ?`);
6465
6933
  queryBindings.push(draftRow[branch.alias]);
6466
6934
  }
6467
6935
  }
6468
- updateClauses.push("updated_at = (unixepoch())");
6469
- const updateSql = `UPDATE ${liveTableName} SET ${updateClauses.join(", ")} WHERE id = ?`;
6470
- 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
+ }
6471
6945
  const batchStmts = [
6472
6946
  this.database.prepare(updateSql).bind(...queryBindings),
6473
6947
  this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId)
6474
6948
  ];
6475
6949
  for (const branch of mRelBranches) {
6476
- const lt = jTable(seed.slug, branch.alias);
6477
6950
  const jdt = jDraftTable(seed.slug, branch.alias);
6478
- const targetIds = mRelDraftIds.get(branch.alias) ?? [];
6479
- batchStmts.push(
6480
- this.database.prepare(`DELETE FROM ${lt} WHERE parent_id = ?`).bind(entryId),
6481
- this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId),
6482
- ...this.buildJunctionInserts(seed.slug, entryId, branch.alias, targetIds)
6483
- );
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
+ }
6484
6964
  }
6485
6965
  await this.database.batch(batchStmts);
6486
6966
  } catch (error) {
@@ -6489,19 +6969,25 @@ var D1ContentRepository = class extends BaseD1Repository {
6489
6969
  throw this.mapError(error, `publishDraft(${seed.slug}, ${entryId})`);
6490
6970
  }
6491
6971
  }
6492
- 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) {
6493
6978
  const CHUNK = 50;
6494
- const tableName = this.getTableName(seedSlug);
6979
+ const tableName = this.getTableName(seed.slug);
6495
6980
  let updated = 0;
6496
6981
  const failed = [];
6497
6982
  for (let offset = 0; offset < ids.length; offset += CHUNK) {
6498
6983
  const chunk = ids.slice(offset, offset + CHUNK);
6499
- const res = await this.processBulkUpdateChunk(seedSlug, chunk, fields, tableName);
6984
+ const res = await this.processBulkUpdateChunk(seed, chunk, fields, tableName);
6500
6985
  updated += res.updated;
6501
6986
  failed.push(...res.failed);
6502
6987
  }
6503
6988
  return { updated, failed };
6504
6989
  }
6990
+ /** Deletes the draft row for `entryId`, if any. A no-op if `seed.allowDrafts` is false. */
6505
6991
  async deleteDraft(seed, entryId) {
6506
6992
  try {
6507
6993
  if (!seed.allowDrafts) return;
@@ -6511,6 +6997,15 @@ var D1ContentRepository = class extends BaseD1Repository {
6511
6997
  throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`);
6512
6998
  }
6513
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
+ */
6514
7009
  async findPendingDrafts(seeds) {
6515
7010
  try {
6516
7011
  if (seeds.length === 0) return [];
@@ -6520,6 +7015,8 @@ var D1ContentRepository = class extends BaseD1Repository {
6520
7015
  const draftTable = `content_${seed.slug}_drafts`;
6521
7016
  const liveTable = `content_${seed.slug}`;
6522
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)`;
6523
7020
  const seedLabel = seed.labelPlural ?? seed.label;
6524
7021
  unionSelects.push(`
6525
7022
  SELECT
@@ -6527,7 +7024,7 @@ var D1ContentRepository = class extends BaseD1Repository {
6527
7024
  ? AS seed_label,
6528
7025
  d.entry_id AS id,
6529
7026
  d.updated_at AS updated_at,
6530
- COALESCE(d.${titleCol}, l.${titleCol}) AS title
7027
+ ${titleExpr} AS title
6531
7028
  FROM ${draftTable} d
6532
7029
  LEFT JOIN ${liveTable} l ON l.id = d.entry_id
6533
7030
  `);
@@ -6574,9 +7071,48 @@ var D1ContentRepository = class extends BaseD1Repository {
6574
7071
  throw this.mapError(error, "findPendingDrafts");
6575
7072
  }
6576
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
+ }
6577
7113
  };
6578
7114
 
6579
- // src/shared/idempotency.repository.d1.ts
7115
+ // src/shared/db/repositories/idempotency.repository.d1.ts
6580
7116
  var D1IdempotencyRepository = class extends BaseD1Repository {
6581
7117
  /**
6582
7118
  * Looks up an idempotency record by its key.
@@ -6627,7 +7163,7 @@ var D1IdempotencyRepository = class extends BaseD1Repository {
6627
7163
  }
6628
7164
  };
6629
7165
 
6630
- // src/shared/media.repository.d1.ts
7166
+ // src/shared/db/repositories/media.repository.d1.ts
6631
7167
  var D1MediaRepository = class {
6632
7168
  constructor(database) {
6633
7169
  this.database = database;
@@ -6683,7 +7219,7 @@ var D1MediaRepository = class {
6683
7219
  }
6684
7220
  };
6685
7221
 
6686
- // src/shared/system-stats.repository.d1.ts
7222
+ // src/shared/db/repositories/system-stats.repository.d1.ts
6687
7223
  var D1SystemStatsRepository = class {
6688
7224
  constructor(database) {
6689
7225
  this.database = database;
@@ -6722,7 +7258,7 @@ var D1SystemStatsRepository = class {
6722
7258
  }
6723
7259
  };
6724
7260
 
6725
- // src/shared/d1-user.repository.ts
7261
+ // src/shared/db/repositories/d1-user.repository.ts
6726
7262
  function rowToRecord(row) {
6727
7263
  return {
6728
7264
  id: row.id,
@@ -6789,7 +7325,7 @@ var D1UserRepository = class {
6789
7325
  }
6790
7326
  };
6791
7327
 
6792
- // src/shared/d1-session.repository.ts
7328
+ // src/shared/db/repositories/d1-session.repository.ts
6793
7329
  function rowToRecord2(row) {
6794
7330
  return {
6795
7331
  id: row.id,
@@ -6848,7 +7384,7 @@ var D1SessionRepository = class {
6848
7384
  }
6849
7385
  };
6850
7386
 
6851
- // src/shared/d1-password-reset-token.repository.ts
7387
+ // src/shared/db/repositories/d1-password-reset-token.repository.ts
6852
7388
  var D1PasswordResetTokenRepository = class {
6853
7389
  constructor(db, idGenerator) {
6854
7390
  this.db = db;
@@ -6878,7 +7414,7 @@ var D1PasswordResetTokenRepository = class {
6878
7414
  }
6879
7415
  };
6880
7416
 
6881
- // src/shared/d1-activity-log.repository.ts
7417
+ // src/shared/db/repositories/d1-activity-log.repository.ts
6882
7418
  var D1ActivityLogRepository = class {
6883
7419
  constructor(database) {
6884
7420
  this.database = database;
@@ -6941,7 +7477,7 @@ function parseDetails(raw) {
6941
7477
  }
6942
7478
  }
6943
7479
 
6944
- // src/shared/d1-notification.repository.ts
7480
+ // src/shared/db/repositories/d1-notification.repository.ts
6945
7481
  var D1NotificationRepository = class {
6946
7482
  constructor(database, clock, idGenerator) {
6947
7483
  this.database = database;
@@ -7005,7 +7541,7 @@ function mapRowToRecord2(row) {
7005
7541
  };
7006
7542
  }
7007
7543
 
7008
- // src/shared/d1-widget.repository.ts
7544
+ // src/shared/db/repositories/d1-widget.repository.ts
7009
7545
  import {
7010
7546
  isDateRange
7011
7547
  } from "@beechcms/core";
@@ -7079,9 +7615,10 @@ var D1WidgetRepository = class {
7079
7615
  const bindings = [];
7080
7616
  const trimmedSearch = options.search?.trim() ?? "";
7081
7617
  if (trimmedSearch.length > 0) {
7618
+ const escapedSearch = trimmedSearch.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
7082
7619
  const displayColumn = this.resolveColumnExpression(seed, seed.displayNameAlias);
7083
- conditions.push(`${displayColumn} LIKE ?`);
7084
- bindings.push(`%${trimmedSearch}%`);
7620
+ conditions.push(`${displayColumn} LIKE ? ESCAPE '\\'`);
7621
+ bindings.push(`%${escapedSearch}%`);
7085
7622
  }
7086
7623
  for (const filter of options.filters ?? []) {
7087
7624
  this.appendFilterCondition(seed, filter, conditions, bindings);
@@ -7282,7 +7819,7 @@ var D1WidgetRepository = class {
7282
7819
  }
7283
7820
  };
7284
7821
 
7285
- // src/shared/d1-search.repository.ts
7822
+ // src/shared/db/repositories/d1-search.repository.ts
7286
7823
  var EMPTY_QUERY_ERROR = "EMPTY_QUERY";
7287
7824
  var D1SearchRepository = class {
7288
7825
  constructor(database) {
@@ -7342,7 +7879,7 @@ function mapFtsRowToResultRow(row) {
7342
7879
  };
7343
7880
  }
7344
7881
 
7345
- // src/shared/d1-analytics.repository.ts
7882
+ // src/shared/db/repositories/d1-analytics.repository.ts
7346
7883
  var SECONDS_PER_DAY2 = 86400;
7347
7884
  var D1AnalyticsRepository = class {
7348
7885
  constructor(database, clock) {
@@ -7394,7 +7931,7 @@ var D1AnalyticsRepository = class {
7394
7931
  }
7395
7932
  };
7396
7933
 
7397
- // src/shared/d1-content-scan.repository.ts
7934
+ // src/shared/db/repositories/d1-content-scan.repository.ts
7398
7935
  var D1ContentScanRepository = class {
7399
7936
  constructor(db) {
7400
7937
  this.db = db;
@@ -7423,7 +7960,7 @@ var D1ContentScanRepository = class {
7423
7960
  }
7424
7961
  };
7425
7962
 
7426
- // src/shared/site-settings.repository.d1.ts
7963
+ // src/shared/db/repositories/site-settings.repository.d1.ts
7427
7964
  var DEFAULTS = {
7428
7965
  siteTitle: "Beech CMS",
7429
7966
  defaultLanguage: "en",
@@ -7463,7 +8000,7 @@ var D1SiteSettingsRepository = class {
7463
8000
  }
7464
8001
  };
7465
8002
 
7466
- // src/shared/demo-data-sql.ts
8003
+ // src/shared/db/migrations/demo-data-sql.ts
7467
8004
  var DEMO_DATA_SQL = `
7468
8005
  INSERT OR IGNORE INTO content_clienti (id, slug, status, name, email, company, tier, account_status, mrr, created_at, updated_at)
7469
8006
  VALUES
@@ -8442,7 +8979,7 @@ VALUES
8442
8979
 
8443
8980
  `;
8444
8981
 
8445
- // src/shared/demo-data.repository.d1.ts
8982
+ // src/shared/db/repositories/demo-data.repository.d1.ts
8446
8983
  var D1DemoDataRepository = class {
8447
8984
  constructor(db) {
8448
8985
  this.db = db;
@@ -8454,7 +8991,7 @@ var D1DemoDataRepository = class {
8454
8991
  }
8455
8992
  };
8456
8993
 
8457
- // src/shared/d1-setup-checklist.repository.ts
8994
+ // src/shared/db/repositories/d1-setup-checklist.repository.ts
8458
8995
  var D1SetupChecklistRepository = class {
8459
8996
  constructor(db) {
8460
8997
  this.db = db;
@@ -8474,7 +9011,8 @@ var D1SetupChecklistRepository = class {
8474
9011
  }
8475
9012
  };
8476
9013
 
8477
- // 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";
8478
9016
  var D1SeedLayoutRepository = class {
8479
9017
  constructor(db) {
8480
9018
  this.db = db;
@@ -8516,9 +9054,25 @@ var D1SeedLayoutRepository = class {
8516
9054
  async remove(slug) {
8517
9055
  await this.db.prepare("DELETE FROM seed_layouts WHERE slug = ?").bind(slug).run();
8518
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
+ }
8519
9073
  };
8520
9074
 
8521
- // src/shared/dashboard-layout.repository.d1.ts
9075
+ // src/shared/db/repositories/dashboard-layout.repository.d1.ts
8522
9076
  var D1DashboardLayoutRepository = class {
8523
9077
  constructor(db) {
8524
9078
  this.db = db;
@@ -8559,7 +9113,7 @@ var D1DashboardLayoutRepository = class {
8559
9113
  }
8560
9114
  };
8561
9115
 
8562
- // src/shared/seed.repository.d1.ts
9116
+ // src/shared/db/repositories/seed.repository.d1.ts
8563
9117
  var D1SeedRepository = class {
8564
9118
  constructor(db) {
8565
9119
  this.db = db;
@@ -8634,7 +9188,7 @@ var D1SeedRepository = class {
8634
9188
  }
8635
9189
  };
8636
9190
 
8637
- // src/shared/schema-mutator.d1.ts
9191
+ // src/shared/db/migrations/schema-mutator.d1.ts
8638
9192
  var D1SchemaMutator = class _D1SchemaMutator {
8639
9193
  constructor(db) {
8640
9194
  this.db = db;
@@ -8677,13 +9231,59 @@ var D1SchemaMutator = class _D1SchemaMutator {
8677
9231
  if (statements.length === 0) return;
8678
9232
  await this.db.batch(statements.map((s) => this.db.prepare(s)));
8679
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
+ }
8680
9280
  };
8681
9281
 
8682
9282
  // src/middleware/repository.middleware.ts
8683
9283
  import { SystemClock as SystemClock2, SystemIdGenerator } from "@beechcms/core";
8684
9284
  import { NoOpScheduler } from "@beechcms/core";
8685
9285
 
8686
- // src/shared/automations.repository.d1.ts
9286
+ // src/shared/db/repositories/automations.repository.d1.ts
8687
9287
  var D1AutomationRepository = class {
8688
9288
  constructor(db) {
8689
9289
  this.db = db;
@@ -8769,7 +9369,7 @@ function rowToAutomation(row) {
8769
9369
  };
8770
9370
  }
8771
9371
 
8772
- // src/shared/execution-context-scheduler.ts
9372
+ // src/shared/services/scheduler/execution-context-scheduler.ts
8773
9373
  var ExecutionContextScheduler = class {
8774
9374
  constructor(ctx) {
8775
9375
  this.ctx = ctx;
@@ -8828,6 +9428,7 @@ var repositoryMiddleware = (overrides) => {
8828
9428
  context.set("seedRepository", overrides?.seedRepository ?? new D1SeedRepository(database));
8829
9429
  context.set("schemaMutator", overrides?.schemaMutator ?? new D1SchemaMutator(database));
8830
9430
  context.set("dashboardLayoutRepository", overrides?.dashboardLayoutRepository ?? new D1DashboardLayoutRepository(database));
9431
+ context.set("kanbanPositionRepository", overrides?.kanbanPositionRepository ?? new D1KanbanPositionRepository(database));
8831
9432
  await next();
8832
9433
  });
8833
9434
  };
@@ -8835,7 +9436,7 @@ var repositoryMiddleware = (overrides) => {
8835
9436
  // src/middleware/seed-registry.middleware.ts
8836
9437
  import { createMiddleware as createMiddleware3 } from "hono/factory";
8837
9438
 
8838
- // src/shared/seed-registry-cache.ts
9439
+ // src/shared/services/cache/seed-registry-cache.ts
8839
9440
  import { SeedRegistry, buildBackrefMap } from "@beechcms/core";
8840
9441
  var cache = null;
8841
9442
  var TTL_MS = 5e3;
@@ -8895,30 +9496,12 @@ var S3Bucket = class {
8895
9496
  const command = new PutObjectCommand({
8896
9497
  Bucket: this.bucketName,
8897
9498
  Key: key,
8898
- Body: body instanceof ReadableStream ? await this.streamToUint8Array(body) : new Uint8Array(body),
9499
+ Body: body instanceof ReadableStream ? body : new Uint8Array(body),
8899
9500
  ContentType: options?.contentType,
8900
9501
  Metadata: options?.metadata
8901
9502
  });
8902
9503
  await this.client.send(command);
8903
9504
  }
8904
- async streamToUint8Array(stream) {
8905
- const reader = stream.getReader();
8906
- const chunks = [];
8907
- let totalLength = 0;
8908
- while (true) {
8909
- const { done, value } = await reader.read();
8910
- if (done) break;
8911
- chunks.push(value);
8912
- totalLength += value.length;
8913
- }
8914
- const result = new Uint8Array(totalLength);
8915
- let offset = 0;
8916
- for (const chunk of chunks) {
8917
- result.set(chunk, offset);
8918
- offset += chunk.length;
8919
- }
8920
- return result;
8921
- }
8922
9505
  async get(key) {
8923
9506
  try {
8924
9507
  const command = new GetObjectCommand({
@@ -8967,10 +9550,11 @@ var S3Bucket = class {
8967
9550
  }
8968
9551
  }
8969
9552
  getUrl(key) {
9553
+ const encodedKey = key.split("/").map(encodeURIComponent).join("/");
8970
9554
  if (this.cdnUrl) {
8971
- return `${this.cdnUrl}/${encodeURIComponent(key)}`;
9555
+ return `${this.cdnUrl}/${encodedKey}`;
8972
9556
  }
8973
- return `${this.baseUrl}/api/media/${encodeURIComponent(key)}`;
9557
+ return `${this.baseUrl}/api/media/${encodedKey}`;
8974
9558
  }
8975
9559
  async getTotalSize() {
8976
9560
  let total = 0;
@@ -8992,7 +9576,8 @@ var S3Bucket = class {
8992
9576
  const command = new PutObjectCommand({
8993
9577
  Bucket: this.bucketName,
8994
9578
  Key: key,
8995
- ContentType: options.contentType
9579
+ ContentType: options.contentType,
9580
+ ContentLength: options.contentLength
8996
9581
  });
8997
9582
  return getSignedUrl(this.client, command, { expiresIn: options.expiresIn });
8998
9583
  }
@@ -9022,9 +9607,21 @@ var S3Bucket = class {
9022
9607
  };
9023
9608
 
9024
9609
  // src/shared/storage/factory.ts
9610
+ import { HTTPException as HTTPException2 } from "hono/http-exception";
9025
9611
  var NullBucket = class {
9026
9612
  fail() {
9027
- 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
+ });
9028
9625
  }
9029
9626
  put() {
9030
9627
  return this.fail();
@@ -9081,7 +9678,7 @@ var storageMiddleware = (overrides) => {
9081
9678
  });
9082
9679
  };
9083
9680
 
9084
- // src/shared/in-memory-seed.repository.ts
9681
+ // src/shared/db/repositories/in-memory-seed.repository.ts
9085
9682
  var InMemorySeedRepository = class {
9086
9683
  seeds;
9087
9684
  version = 1;
@@ -9127,7 +9724,7 @@ var InMemorySeedRepository = class {
9127
9724
  import { createMiddleware as createMiddleware5 } from "hono/factory";
9128
9725
  import { SystemClock as SystemClock3 } from "@beechcms/core";
9129
9726
 
9130
- // src/auth/bcrypt-hash-provider.ts
9727
+ // src/auth/providers/hash.provider.ts
9131
9728
  import bcrypt from "bcryptjs";
9132
9729
  var BCRYPT_SALT_ROUNDS = 10;
9133
9730
  var BcryptHashProvider = class {
@@ -9143,7 +9740,7 @@ var BcryptHashProvider = class {
9143
9740
  }
9144
9741
  };
9145
9742
 
9146
- // src/auth/jose-token-service.ts
9743
+ // src/auth/providers/jwt-token.service.ts
9147
9744
  import { SignJWT, jwtVerify } from "jose";
9148
9745
  var DEFAULT_TOKEN_TTL_SECONDS = 900;
9149
9746
  var DEFAULT_ALGORITHM = "HS256";
@@ -9175,8 +9772,12 @@ var JoseTokenService = class {
9175
9772
  const { payload } = await jwtVerify(token, this.secretBytes, {
9176
9773
  algorithms: [algorithm],
9177
9774
  issuer: this.config.issuer,
9178
- audience: this.config.audience
9775
+ audience: this.config.audience,
9776
+ currentDate: new Date(this.clock.nowSeconds() * 1e3)
9179
9777
  });
9778
+ if (typeof payload.sub !== "string" || payload.sub === "") {
9779
+ return null;
9780
+ }
9180
9781
  return payload;
9181
9782
  } catch {
9182
9783
  return null;
@@ -9214,13 +9815,23 @@ import { createMiddleware as createMiddleware6 } from "hono/factory";
9214
9815
 
9215
9816
  // src/rate-limit/cloudflare-rate-limiter.ts
9216
9817
  var CloudflareRateLimiter = class {
9217
- constructor(binding) {
9818
+ constructor(binding, options = {}) {
9218
9819
  this.binding = binding;
9820
+ this.options = options;
9219
9821
  }
9220
9822
  binding;
9823
+ options;
9221
9824
  async checkLimit(key) {
9222
- const { success } = await this.binding.limit({ key });
9223
- 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
+ }
9224
9835
  }
9225
9836
  };
9226
9837
 
@@ -9235,13 +9846,20 @@ var NoOpRateLimiter = class {
9235
9846
  var NO_OP = new NoOpRateLimiter();
9236
9847
  function buildDefaultRegistry(env) {
9237
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
+ };
9238
9856
  const limiters = {
9239
- login: !isDev && env.LOGIN_RATE_LIMITER ? new CloudflareRateLimiter(env.LOGIN_RATE_LIMITER) : NO_OP,
9240
- tokenRefresh: !isDev && env.REFRESH_RATE_LIMITER ? new CloudflareRateLimiter(env.REFRESH_RATE_LIMITER) : NO_OP,
9241
- forgotPassword: !isDev && env.FORGOT_PASSWORD_RATE_LIMITER ? new CloudflareRateLimiter(env.FORGOT_PASSWORD_RATE_LIMITER) : NO_OP,
9242
- resetPassword: !isDev && env.RESET_PASSWORD_RATE_LIMITER ? new CloudflareRateLimiter(env.RESET_PASSWORD_RATE_LIMITER) : NO_OP,
9243
- publicApiRead: !isDev && env.PUBLIC_READ_RATE_LIMITER ? new CloudflareRateLimiter(env.PUBLIC_READ_RATE_LIMITER) : NO_OP,
9244
- publicApiWrite: !isDev && env.PUBLIC_WRITE_RATE_LIMITER ? new CloudflareRateLimiter(env.PUBLIC_WRITE_RATE_LIMITER) : NO_OP
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 })
9245
9863
  };
9246
9864
  return { getLimiter: (name) => limiters[name] };
9247
9865
  }
@@ -9257,7 +9875,7 @@ var rateLimiterMiddleware = (overrides) => {
9257
9875
  import { createMiddleware as createMiddleware7 } from "hono/factory";
9258
9876
  import { SystemClock as SystemClock4, SystemIdGenerator as SystemIdGenerator2 } from "@beechcms/core";
9259
9877
 
9260
- // src/shared/d1-activity-logger.ts
9878
+ // src/shared/services/activity-log/d1-activity-logger.ts
9261
9879
  var ACTIVITY_LOG_INSERT_SQL = `INSERT INTO activity_logs
9262
9880
  (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
9263
9881
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
@@ -9302,7 +9920,7 @@ var D1ActivityLogger = class {
9302
9920
  }
9303
9921
  };
9304
9922
 
9305
- // src/shared/background-notification-service.ts
9923
+ // src/shared/services/notification/background-notification-service.ts
9306
9924
  var DEFAULT_NOTIFICATION_TYPE = "info";
9307
9925
  var BackgroundNotificationService = class {
9308
9926
  constructor(notificationRepository, scheduleBackgroundTask) {
@@ -9332,7 +9950,7 @@ var BackgroundNotificationService = class {
9332
9950
  }
9333
9951
  };
9334
9952
 
9335
- // src/shared/qstash-notification-service.ts
9953
+ // src/shared/services/notification/qstash-notification-service.ts
9336
9954
  import { Client } from "@upstash/qstash";
9337
9955
  var QStashNotificationService = class {
9338
9956
  constructor(token, appUrl, scheduleBackgroundTask, qstashUrl) {
@@ -9405,7 +10023,7 @@ var observabilityMiddleware = (overrides) => {
9405
10023
  import { createMiddleware as createMiddleware8 } from "hono/factory";
9406
10024
  import { SystemClock as SystemClock5, SystemIdGenerator as SystemIdGenerator3 } from "@beechcms/core";
9407
10025
 
9408
- // src/shared/cloudflare-queue-service.ts
10026
+ // src/shared/services/queue/cloudflare-queue-service.ts
9409
10027
  var CloudflareQueueService = class {
9410
10028
  constructor(queue) {
9411
10029
  this.queue = queue;
@@ -9420,7 +10038,7 @@ var CloudflareQueueService = class {
9420
10038
  }
9421
10039
  };
9422
10040
 
9423
- // src/shared/in-memory-queue-service.ts
10041
+ // src/shared/services/queue/in-memory-queue-service.ts
9424
10042
  var InMemoryQueueService = class {
9425
10043
  constructor(jobs, context, scheduleBackgroundTask) {
9426
10044
  this.jobs = jobs;
@@ -9547,7 +10165,7 @@ function createBeechApp(config) {
9547
10165
  try {
9548
10166
  const originUrl = new URL(origin);
9549
10167
  const requestUrl = new URL(context.req.url);
9550
- if (originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
10168
+ if (originUrl.protocol === requestUrl.protocol && originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
9551
10169
  return origin;
9552
10170
  }
9553
10171
  } catch {
@@ -9601,7 +10219,13 @@ function createBeechApp(config) {
9601
10219
  if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400);
9602
10220
  const clientIp = getClientIp(context.req);
9603
10221
  const loginRateLimit = await context.get("rateLimiters").getLimiter("login").checkLimit(clientIp);
9604
- 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
+ }
9605
10229
  const user = await context.get("userRepository").findByEmail(email);
9606
10230
  const hashToCompare = user?.passwordHash ?? DUMMY_PASSWORD_HASH;
9607
10231
  const isValid = await verifyPassword(password, hashToCompare, context.get("hashProvider"));
@@ -9626,7 +10250,13 @@ function createBeechApp(config) {
9626
10250
  try {
9627
10251
  const refreshClientIp = getClientIp(context.req);
9628
10252
  const refreshRateLimit = await context.get("rateLimiters").getLimiter("tokenRefresh").checkLimit(refreshClientIp);
9629
- 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
+ }
9630
10260
  const refreshToken = getCookie(context, "refresh_token");
9631
10261
  if (!refreshToken) return context.json({ error: "Refresh token missing" }, 401);
9632
10262
  const nowSeconds = SystemClock6.nowSeconds();
@@ -9647,10 +10277,19 @@ function createBeechApp(config) {
9647
10277
  tokenHash: newRefreshTokenHash,
9648
10278
  expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY3
9649
10279
  });
9650
- const revoked = await context.get("sessionRepository").revokeByHash(tokenHash, nowSeconds);
9651
- if (!revoked) return context.json({ error: "Invalid refresh token" }, 401);
9652
- setCookie(context, "refresh_token", newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)));
9653
- return context.json({ token: newAccessToken, expiresIn: "15m" }, 200);
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
+ }
9654
10293
  } catch (error) {
9655
10294
  return handleAuthError(context, error, "Refresh");
9656
10295
  }
@@ -9692,7 +10331,7 @@ function createBeechApp(config) {
9692
10331
  apiPublic.route("/", publicRoutes);
9693
10332
  app.route("/api/v1/public", apiPublic);
9694
10333
  app.route("/api/webhooks", webhooksApp);
9695
- app.get("/api/media/:key", (context) => serveMediaHandler(context));
10334
+ app.get("/api/media/:key{.+}", (context) => serveMediaHandler(context));
9696
10335
  if (config.customRoutes) {
9697
10336
  const publicRouter = new Hono19();
9698
10337
  const protectedRouter = new Hono19();