@beechcms/api 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -188,7 +188,8 @@ async function applyPrivacy(data, seed) {
188
188
  );
189
189
  }
190
190
  if (privacy === "hash" && value != null) {
191
- result[alias] = await sha256hex(String(value));
191
+ const serialized = typeof value === "string" ? value : JSON.stringify(value);
192
+ result[alias] = await sha256hex(serialized);
192
193
  } else {
193
194
  result[alias] = value;
194
195
  }
@@ -860,6 +861,7 @@ async function updateHandler(context) {
860
861
  // src/shared/storage/upload.ts
861
862
  async function deleteR2Objects(c, objectKeys) {
862
863
  const { bucket, mediaRepository, systemStatsRepository } = c.var;
864
+ const untrackFailures = [];
863
865
  await Promise.all(
864
866
  objectKeys.map(async (key) => {
865
867
  const media = await mediaRepository.getByKey(key).catch(() => null);
@@ -875,9 +877,15 @@ async function deleteR2Objects(c, objectKeys) {
875
877
  }
876
878
  } catch (err) {
877
879
  console.warn(`Failed to untrack media object: ${key}`, err);
880
+ untrackFailures.push(key);
878
881
  }
879
882
  })
880
883
  );
884
+ if (untrackFailures.length > 0) {
885
+ throw new Error(
886
+ `${untrackFailures.length} media row(s) now out of sync (R2 object deleted but DB untrack/decrement failed): ${untrackFailures.join(", ")}`
887
+ );
888
+ }
881
889
  }
882
890
 
883
891
  // src/shared/utils/media-utils.ts
@@ -885,11 +893,14 @@ var MEDIA_URL_PATTERN = /\/api\/media\/([^?#]+)/;
885
893
  function extractMediaKey(mediaUrl, cdnUrl) {
886
894
  const urlStr = String(mediaUrl);
887
895
  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;
896
+ try {
897
+ const cdnOrigin = new URL(cdnUrl).origin;
898
+ const mediaUrlParsed = new URL(urlStr);
899
+ if (mediaUrlParsed.origin === cdnOrigin) {
900
+ const keyPart = mediaUrlParsed.pathname.replace(/^\//, "");
901
+ return keyPart ? decodeURIComponent(keyPart) : null;
902
+ }
903
+ } catch {
893
904
  }
894
905
  }
895
906
  const match = MEDIA_URL_PATTERN.exec(urlStr);
@@ -1859,7 +1870,7 @@ function resolveEmailLocale(raw) {
1859
1870
  import { sha256hex as sha256hex3 } from "@beechcms/core";
1860
1871
  var PASSWORD_RESET_TOKEN_EXPIRY_SECONDS = 30 * 60;
1861
1872
  async function requestPasswordReset(context) {
1862
- const { env, req } = context;
1873
+ const { env, req, executionCtx } = context;
1863
1874
  const useSmtp = env.EMAIL_PROVIDER === "smtp";
1864
1875
  if (!useSmtp && !env.RESEND_API_KEY) {
1865
1876
  return context.json({ error: "Service not available" }, 503);
@@ -1902,21 +1913,28 @@ async function requestPasswordReset(context) {
1902
1913
  const baseUrl = (env.APP_URL ?? new URL(req.url).origin).replace(/\/$/, "");
1903
1914
  const resetUrl = `${baseUrl}/admin/reset-password?token=${resetToken}`;
1904
1915
  const smtpBaseUrl = env.SMTP_HOST ? `http://${env.SMTP_HOST}:${env.SMTP_PORT ?? "8025"}` : void 0;
1905
- try {
1906
- await sendPasswordResetEmail({
1907
- to: normalizedEmail,
1908
- resetUrl,
1909
- locale: emailLocale,
1910
- apiKey: env.RESEND_API_KEY ?? "",
1911
- from: env.EMAIL_FROM,
1912
- isDev: env.ENV !== "production",
1913
- provider: env.EMAIL_PROVIDER,
1914
- smtpBaseUrl
1915
- });
1916
- } catch (error) {
1917
- if (env.ENV !== "production") {
1918
- console.error("[password-reset] Failed to send email:", error);
1916
+ const sendNotification = async () => {
1917
+ try {
1918
+ await sendPasswordResetEmail({
1919
+ to: normalizedEmail,
1920
+ resetUrl,
1921
+ locale: emailLocale,
1922
+ apiKey: env.RESEND_API_KEY ?? "",
1923
+ from: env.EMAIL_FROM,
1924
+ isDev: env.ENV !== "production",
1925
+ provider: env.EMAIL_PROVIDER,
1926
+ smtpBaseUrl
1927
+ });
1928
+ } catch (error) {
1929
+ if (env.ENV !== "production") {
1930
+ console.error("[password-reset] Failed to send email:", error);
1931
+ }
1919
1932
  }
1933
+ };
1934
+ try {
1935
+ executionCtx.waitUntil(sendNotification());
1936
+ } catch {
1937
+ void sendNotification();
1920
1938
  }
1921
1939
  return context.json({ success: true });
1922
1940
  }
@@ -2153,6 +2171,26 @@ setupApp.post("/auth/setup", async (context) => {
2153
2171
  });
2154
2172
  }
2155
2173
  }
2174
+ const passwordHash = await context.get("hashProvider").hash(password);
2175
+ const normalizedEmail = email.trim().toLowerCase();
2176
+ const normalizedName = typeof name === "string" ? name.trim() : null;
2177
+ const normalizedSurname = typeof surname === "string" ? surname.trim() : null;
2178
+ const created = await context.get("userRepository").createInitialAdmin({
2179
+ id: context.get("idGenerator").uuid(),
2180
+ email: normalizedEmail,
2181
+ passwordHash,
2182
+ role: "admin",
2183
+ name: normalizedName,
2184
+ surname: normalizedSurname
2185
+ });
2186
+ if (!created) {
2187
+ return publicProblem(context, {
2188
+ type: "setup-already-done",
2189
+ title: "Setup already completed",
2190
+ status: 403,
2191
+ detail: "An administrator account already exists. Initial setup can only be performed once."
2192
+ });
2193
+ }
2156
2194
  if (track === "developer" && loadDemoData === true) {
2157
2195
  await context.get("demoDataRepository").loadDemoData();
2158
2196
  const layout = {
@@ -2238,18 +2276,6 @@ setupApp.post("/auth/setup", async (context) => {
2238
2276
  };
2239
2277
  await context.get("dashboardLayoutRepository").upsert("default", layout, "system");
2240
2278
  }
2241
- const passwordHash = await context.get("hashProvider").hash(password);
2242
- const normalizedEmail = email.trim().toLowerCase();
2243
- const normalizedName = typeof name === "string" ? name.trim() : null;
2244
- const normalizedSurname = typeof surname === "string" ? surname.trim() : null;
2245
- await context.get("userRepository").create({
2246
- id: context.get("idGenerator").uuid(),
2247
- email: normalizedEmail,
2248
- passwordHash,
2249
- role: "admin",
2250
- name: normalizedName,
2251
- surname: normalizedSurname
2252
- });
2253
2279
  if (track === "normal" && company && typeof company === "object") {
2254
2280
  const c = company;
2255
2281
  const companyName = c.name.trim();
@@ -3131,7 +3157,8 @@ async function deleteSeedMediaObjects(context, slug, seed, schemaMutator) {
3131
3157
  }
3132
3158
  }
3133
3159
  if (r2Keys.length > 0) {
3134
- await deleteR2Objects(context, r2Keys).catch(() => {
3160
+ await deleteR2Objects(context, r2Keys).catch((error) => {
3161
+ console.warn(`Seed drop for '${slug}' left media rows out of sync:`, error);
3135
3162
  });
3136
3163
  }
3137
3164
  } catch {
@@ -3620,10 +3647,13 @@ function interpolate(template, context, defaultValue = "", onMissing) {
3620
3647
  if (val == null || val === "") {
3621
3648
  return defaultValue;
3622
3649
  }
3623
- return String(val);
3650
+ return escapeHtml(String(val));
3624
3651
  };
3625
3652
  return template.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, replacer);
3626
3653
  }
3654
+ function escapeHtml(input) {
3655
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3656
+ }
3627
3657
  function resolvePath(obj, path) {
3628
3658
  if (path in obj && obj[path] !== void 0) {
3629
3659
  return obj[path];
@@ -5046,7 +5076,8 @@ webhooksApp.post("/qstash", async (context) => {
5046
5076
  try {
5047
5077
  const isValid = await receiver.verify({
5048
5078
  signature,
5049
- body
5079
+ body,
5080
+ url: context.req.url
5050
5081
  });
5051
5082
  if (!isValid) {
5052
5083
  return context.text("Invalid signature", 401);
@@ -5304,7 +5335,7 @@ function publicRateLimitMiddleware() {
5304
5335
  const remaining = path.slice("/api/v1/public/".length);
5305
5336
  const firstSegment = remaining.split("/")[0];
5306
5337
  if (firstSegment && firstSegment !== "health" && firstSegment !== "schema" && firstSegment !== "schema.html") {
5307
- seed = firstSegment;
5338
+ seed = c.get("seedRegistry").get(firstSegment) ? firstSegment : "invalid-seed";
5308
5339
  }
5309
5340
  }
5310
5341
  const key = `${getClientIp(c.req)}:${seed}:${limiterName}`;
@@ -5392,7 +5423,11 @@ function applyPublicPolicies(data, seed) {
5392
5423
  const { public: isPublic, visibility } = resolvePolicies7(branch);
5393
5424
  if (!isPublic) continue;
5394
5425
  if (visibility === "hidden") continue;
5395
- result[branch.alias] = visibility === "masked" && typeof value === "string" && value.length > 0 ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : value;
5426
+ if (visibility === "masked") {
5427
+ result[branch.alias] = typeof value === "string" && value.length > 0 ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : null;
5428
+ } else {
5429
+ result[branch.alias] = value;
5430
+ }
5396
5431
  }
5397
5432
  return result;
5398
5433
  }
@@ -5447,6 +5482,7 @@ async function readSingleEntry(input) {
5447
5482
  }
5448
5483
 
5449
5484
  // src/public/query-builder.ts
5485
+ import { resolvePolicies as resolvePolicies8 } from "@beechcms/core";
5450
5486
  var PUBLIC_FILTER_OPERATORS = /* @__PURE__ */ new Set([
5451
5487
  "eq",
5452
5488
  "neq",
@@ -5526,6 +5562,12 @@ function toEngineFilters2(seed, parsedFilter) {
5526
5562
  if (!parsedFilter || parsedFilter.where.length === 0) return [];
5527
5563
  return parsedFilter.where.map((cond) => {
5528
5564
  const branch = seed.branches.find((b) => b.alias === cond.field);
5565
+ if (branch) {
5566
+ const { public: isPublic, filter: filterable } = resolvePolicies8(branch);
5567
+ if (!isPublic || !filterable) {
5568
+ throw new TypeError(`Invalid filter: field '${cond.field}' is not filterable`);
5569
+ }
5570
+ }
5529
5571
  const type = branch ? mapBranchToFilterType(branch.type) : SYSTEM_COLUMNS2.has(cond.field) ? "system" : "text";
5530
5572
  return {
5531
5573
  column: cond.field,
@@ -5633,7 +5675,7 @@ async function publicReadHandler(context) {
5633
5675
  }
5634
5676
 
5635
5677
  // src/public/public-add.ts
5636
- import { isValidContentStatus as isValidContentStatus3, SlugConflictError as SlugConflictError2 } from "@beechcms/core";
5678
+ import { isValidContentStatus as isValidContentStatus3, resolvePolicies as resolvePolicies9, SlugConflictError as SlugConflictError2 } from "@beechcms/core";
5637
5679
 
5638
5680
  // src/public/slug-utils.ts
5639
5681
  import { slugify as slugify3, generateEntrySlug } from "@beechcms/core";
@@ -5718,6 +5760,13 @@ async function publicAddHandler(context) {
5718
5760
  if (!isValidContentStatus3(statusValue)) {
5719
5761
  return publicProblem(context, { type: "invalid-status", title: "Bad Request", status: 400, detail: "Invalid status. Allowed values are: draft, review, published" });
5720
5762
  }
5763
+ const sensitiveAliases = Object.keys(rawData).filter((alias) => {
5764
+ const branch = seed.branches.find((b) => b.alias === alias);
5765
+ return branch != null && resolvePolicies9(branch).public === false;
5766
+ });
5767
+ if (sensitiveAliases.length > 0) {
5768
+ return publicProblem(context, { type: "sensitive-field-edit", title: "Unprocessable Entity", status: 422, detail: `Cannot write internal fields: ${sensitiveAliases.join(", ")}` });
5769
+ }
5721
5770
  const sanitized = sanitizePublicPayload(seed, rawData, { operation: "create", allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: true });
5722
5771
  if (!sanitized.ok) {
5723
5772
  if (sanitized.status === 422) {
@@ -5725,8 +5774,17 @@ async function publicAddHandler(context) {
5725
5774
  }
5726
5775
  return publicProblem(context, { type: sanitized.code, title: "Bad Request", status: 400, detail: sanitized.message, errors: sanitized.details });
5727
5776
  }
5777
+ let privacyData;
5778
+ try {
5779
+ privacyData = await applyPrivacy(sanitized.data, seed);
5780
+ } catch (error) {
5781
+ if (error instanceof PrivacyPolicyError) {
5782
+ return publicProblem(context, { type: "policy-not-implemented", title: "Not Implemented", status: 501, detail: error.message });
5783
+ }
5784
+ throw error;
5785
+ }
5728
5786
  const idempotencyKey = parseIdempotencyKey(context.req.header("Idempotency-Key"));
5729
- const finalSlug = pickSlug(body, sanitized.data) || context.get("idGenerator").uuid().slice(0, 8);
5787
+ const finalSlug = pickSlug(body, privacyData) || context.get("idGenerator").uuid().slice(0, 8);
5730
5788
  const repository = context.get("repository");
5731
5789
  const idempotencyRepository = context.get("idempotencyRepository");
5732
5790
  try {
@@ -5750,7 +5808,7 @@ async function publicAddHandler(context) {
5750
5808
  }
5751
5809
  const id = context.get("idGenerator").uuid();
5752
5810
  try {
5753
- await repository.create(seed, id, finalSlug, statusValue, sanitized.data);
5811
+ await repository.create(seed, id, finalSlug, statusValue, privacyData);
5754
5812
  } catch (error) {
5755
5813
  if (error instanceof SlugConflictError2) {
5756
5814
  return publicProblem(context, { type: "slug-conflict", title: "Conflict", status: 409, detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.` });
@@ -5763,7 +5821,7 @@ async function publicAddHandler(context) {
5763
5821
  }
5764
5822
  context.get("notificationService").notify({
5765
5823
  title: `${seed.label}: New entry`,
5766
- message: `A new entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") has been added via the public API.`,
5824
+ message: `A new entry ("${privacyData.title || privacyData.name || finalSlug}") has been added via the public API.`,
5767
5825
  type: "success"
5768
5826
  });
5769
5827
  return context.json(responseBody, 201);
@@ -5774,7 +5832,7 @@ async function publicAddHandler(context) {
5774
5832
  }
5775
5833
 
5776
5834
  // src/public/public-edit.ts
5777
- import { isValidContentStatus as isValidContentStatus4, resolvePolicies as resolvePolicies8, EntryNotFoundError as EntryNotFoundError7 } from "@beechcms/core";
5835
+ import { isValidContentStatus as isValidContentStatus4, resolvePolicies as resolvePolicies10, EntryNotFoundError as EntryNotFoundError7 } from "@beechcms/core";
5778
5836
  var UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
5779
5837
  function errorMessage(context, error) {
5780
5838
  if (context.env.ENV !== "production" && error instanceof Error) return error.message;
@@ -5824,7 +5882,7 @@ function resolveData(context, seed, body) {
5824
5882
  const sensitiveAliases = Object.keys(rawData).filter((alias) => {
5825
5883
  const branch = seed.branches.find((b) => b.alias === alias);
5826
5884
  if (!branch) return false;
5827
- const policies = resolvePolicies8(branch);
5885
+ const policies = resolvePolicies10(branch);
5828
5886
  return policies.privacy !== "plain" || policies.public === false;
5829
5887
  });
5830
5888
  if (sensitiveAliases.length > 0) {
@@ -6782,21 +6840,12 @@ var D1ContentRepository = class extends BaseD1Repository {
6782
6840
  const draftTableName = this.getTableName(seed.slug, true);
6783
6841
  const mRelBranches = multiRelBranches(seed);
6784
6842
  const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
6785
- let existingTouched = [];
6786
- try {
6787
- const existing = await this.database.prepare(`SELECT _touched_fields FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId).first();
6788
- if (existing && existing._touched_fields) {
6789
- existingTouched = JSON.parse(existing._touched_fields);
6790
- }
6791
- } catch {
6792
- }
6793
- const currentTouched = new Set(existingTouched);
6843
+ const thisCallTouched = [];
6794
6844
  for (const branch of seed.branches) {
6795
6845
  if (Object.hasOwn(data, branch.alias)) {
6796
- currentTouched.add(branch.alias);
6846
+ thisCallTouched.push(branch.alias);
6797
6847
  }
6798
6848
  }
6799
- const updatedTouchedFields = Array.from(currentTouched);
6800
6849
  const columnNames = ["entry_id"];
6801
6850
  const placeholders = ["?"];
6802
6851
  const queryBindings = [entryId];
@@ -6813,8 +6862,14 @@ var D1ContentRepository = class extends BaseD1Repository {
6813
6862
  }
6814
6863
  columnNames.push("_touched_fields");
6815
6864
  placeholders.push("?");
6816
- queryBindings.push(JSON.stringify(updatedTouchedFields));
6817
- updateClauses.push(`_touched_fields = EXCLUDED._touched_fields`);
6865
+ queryBindings.push(JSON.stringify(thisCallTouched));
6866
+ updateClauses.push(`_touched_fields = (
6867
+ SELECT json_group_array(value) FROM (
6868
+ SELECT value FROM json_each(COALESCE(_touched_fields, '[]'))
6869
+ UNION
6870
+ SELECT value FROM json_each(excluded._touched_fields)
6871
+ )
6872
+ )`);
6818
6873
  updateClauses.push("updated_at = (unixepoch())");
6819
6874
  const sql = `
6820
6875
  INSERT INTO ${draftTableName} (${columnNames.join(", ")})
@@ -7304,6 +7359,18 @@ var D1UserRepository = class {
7304
7359
  async create(user) {
7305
7360
  await this.db.prepare("INSERT INTO users (id, email, password_hash, role, name, surname) VALUES (?, ?, ?, ?, ?, ?)").bind(user.id, user.email, user.passwordHash, user.role, user.name, user.surname).run();
7306
7361
  }
7362
+ async createInitialAdmin(user) {
7363
+ try {
7364
+ await this.db.batch([
7365
+ this.db.prepare("INSERT INTO setup_completed (id) VALUES (1)"),
7366
+ this.db.prepare("INSERT INTO users (id, email, password_hash, role, name, surname) VALUES (?, ?, ?, ?, ?, ?)").bind(user.id, user.email, user.passwordHash, user.role, user.name, user.surname)
7367
+ ]);
7368
+ return true;
7369
+ } catch (err) {
7370
+ if (err instanceof Error && err.message.includes("UNIQUE constraint failed")) return false;
7371
+ throw err;
7372
+ }
7373
+ }
7307
7374
  async updateProfile(userId, fields) {
7308
7375
  const columnAssignments = [];
7309
7376
  const boundValues = [];
@@ -6,6 +6,7 @@ export declare class D1UserRepository implements IUserRepository {
6
6
  findById(userId: string): Promise<UserRecord | null>;
7
7
  findByEmail(email: string): Promise<UserRecord | null>;
8
8
  create(user: NewUserInput): Promise<void>;
9
+ createInitialAdmin(user: NewUserInput): Promise<boolean>;
9
10
  updateProfile(userId: string, fields: {
10
11
  name?: string;
11
12
  surname?: string;
@@ -1,5 +1,4 @@
1
1
  import type { AutomationMailParams } from '../email.types';
2
- /** Identity builder: automation payloads are already user-authored. */
3
2
  export declare function buildAutomationEmail(params: AutomationMailParams): {
4
3
  to: string;
5
4
  subject: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beechcms/api",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/factory.d.ts",
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "@aws-sdk/client-s3": "^3.995.0",
22
22
  "@aws-sdk/s3-request-presigner": "^3.995.0",
23
- "@beechcms/core": "^0.6.1",
23
+ "@beechcms/core": "^0.6.3",
24
24
  "@upstash/qstash": "^2.11.0",
25
25
  "bcryptjs": "^2.4.3",
26
26
  "hono": "^4.12.21",