@dyrected/core 2.17.0 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -6084,6 +6084,72 @@ function createReadonlyDb(db) {
6084
6084
  });
6085
6085
  }
6086
6086
 
6087
+ // src/utils/errors.ts
6088
+ var DyrectedError = class _DyrectedError extends Error {
6089
+ statusCode;
6090
+ constructor(message, statusCode = 500) {
6091
+ super(message);
6092
+ this.name = "DyrectedError";
6093
+ this.statusCode = statusCode;
6094
+ Object.setPrototypeOf(this, _DyrectedError.prototype);
6095
+ }
6096
+ };
6097
+ var ValidationError = class _ValidationError extends DyrectedError {
6098
+ constructor(message) {
6099
+ super(message, 400);
6100
+ this.name = "ValidationError";
6101
+ Object.setPrototypeOf(this, _ValidationError.prototype);
6102
+ }
6103
+ };
6104
+
6105
+ // src/utils/field-integrity.ts
6106
+ function* storedFields(fields) {
6107
+ for (const field of fields ?? []) {
6108
+ if (field.type === "row") {
6109
+ yield* storedFields(field.fields);
6110
+ } else if (field.name) {
6111
+ yield field;
6112
+ }
6113
+ }
6114
+ }
6115
+ function sameValue(a, b) {
6116
+ if (a === b) return true;
6117
+ if (a === null || b === null || a === void 0 || b === void 0) return a == b;
6118
+ if (typeof a === "object" || typeof b === "object") {
6119
+ return JSON.stringify(a) === JSON.stringify(b);
6120
+ }
6121
+ return false;
6122
+ }
6123
+ function assertImmutableFieldsUnchanged(fields, data, original) {
6124
+ for (const field of storedFields(fields)) {
6125
+ if (!field.immutable || data[field.name] === void 0) continue;
6126
+ if (!sameValue(data[field.name], original[field.name])) {
6127
+ throw new ValidationError(
6128
+ `Field "${field.label ?? field.name}" is immutable and cannot be modified.`
6129
+ );
6130
+ }
6131
+ }
6132
+ }
6133
+ function normalizeMoneyFields(fields, data) {
6134
+ let next = data;
6135
+ for (const field of storedFields(fields)) {
6136
+ if (field.type !== "money") continue;
6137
+ const value = data[field.name];
6138
+ if (value === void 0 || value === null || value === "") continue;
6139
+ let minor;
6140
+ if (typeof value === "number") minor = value;
6141
+ else if (typeof value === "string" && /^-?\d+$/.test(value.trim())) minor = Number(value.trim());
6142
+ if (minor === void 0 || !Number.isSafeInteger(minor)) {
6143
+ throw new ValidationError(
6144
+ `Field "${field.label ?? field.name}" must be a whole number of minor units (for example kobo or cents).`
6145
+ );
6146
+ }
6147
+ if (next === data) next = { ...data };
6148
+ next[field.name] = minor;
6149
+ }
6150
+ return next;
6151
+ }
6152
+
6087
6153
  // src/utils/upload-validation.ts
6088
6154
  function isMimeAllowed(mimeType, allowed) {
6089
6155
  const type = (mimeType || "").toLowerCase().trim();
@@ -6679,7 +6745,7 @@ async function dispatchLifecycleEvent(config, event) {
6679
6745
  }
6680
6746
  }
6681
6747
  async function saveWorkflowDraft(args) {
6682
- const { config, collection, id, originalDoc, data, user } = args;
6748
+ const { config, collection, id, originalDoc, data, user, beforeCommit } = args;
6683
6749
  const db = config.db;
6684
6750
  const workflow = collection.workflow;
6685
6751
  if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
@@ -6713,13 +6779,14 @@ async function saveWorkflowDraft(args) {
6713
6779
  }
6714
6780
  const updated = await tx.update({ collection: collection.slug, id, data: updateData });
6715
6781
  await persistEvent(tx, event);
6782
+ await beforeCommit?.(tx, updated);
6716
6783
  return updated;
6717
6784
  });
6718
6785
  void dispatchLifecycleEvent(config, event);
6719
6786
  return { doc, event };
6720
6787
  }
6721
6788
  async function createWorkflowDocument(args) {
6722
- const { config, collection, data, user } = args;
6789
+ const { config, collection, data, user, beforeCommit } = args;
6723
6790
  const db = config.db;
6724
6791
  if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
6725
6792
  let event;
@@ -6733,13 +6800,14 @@ async function createWorkflowDocument(args) {
6733
6800
  payload: { revision: 1, previousRevision: null }
6734
6801
  });
6735
6802
  await persistEvent(tx, event);
6803
+ await beforeCommit?.(tx, created);
6736
6804
  return created;
6737
6805
  });
6738
6806
  void dispatchLifecycleEvent(config, event);
6739
6807
  return { doc, event };
6740
6808
  }
6741
6809
  async function transitionWorkflow(args) {
6742
- const { config, collection, id, transitionName, expectedRevision, comment, user, req } = args;
6810
+ const { config, collection, id, transitionName, expectedRevision, comment, input, user, req } = args;
6743
6811
  const db = config.db;
6744
6812
  const workflow = collection.workflow;
6745
6813
  if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
@@ -6765,7 +6833,7 @@ async function transitionWorkflow(args) {
6765
6833
  if (transition.requireComment && !comment?.trim()) {
6766
6834
  throw Object.assign(new Error(`A comment is required for "${transition.label}".`), { statusCode: 400 });
6767
6835
  }
6768
- const hookContext = { transition, from: meta.state, to: transition.to, doc: original, user, comment, req, db };
6836
+ const hookContext = { transition, from: meta.state, to: transition.to, doc: original, user, comment, input, req, db };
6769
6837
  for (const hook of workflow.hooks?.beforeTransition ?? []) await hook(hookContext);
6770
6838
  const events = [
6771
6839
  createLifecycleEvent({
@@ -6819,6 +6887,19 @@ async function transitionWorkflow(args) {
6819
6887
  if (targetState.published) data.__published = working;
6820
6888
  if (transition.unpublish) data.__published = null;
6821
6889
  const next = await tx.update({ collection: collection.slug, id, data });
6890
+ if (transition.onTransition) {
6891
+ await transition.onTransition({
6892
+ transition,
6893
+ from: lockedMeta.state,
6894
+ to: transition.to,
6895
+ doc: locked,
6896
+ input,
6897
+ comment,
6898
+ user,
6899
+ req,
6900
+ tx
6901
+ });
6902
+ }
6822
6903
  await tx.create({
6823
6904
  collection: WORKFLOW_HISTORY_COLLECTION,
6824
6905
  data: {
@@ -7458,6 +7539,7 @@ var CollectionController = class {
7458
7539
  updatedBy: user?.sub ?? null
7459
7540
  };
7460
7541
  data = DefaultsService.apply(this.collection.fields, data);
7542
+ data = normalizeMoneyFields(this.collection.fields, data);
7461
7543
  if (this.collection.workflow) {
7462
7544
  data = initializeWorkflowDocument(data, this.collection.workflow);
7463
7545
  }
@@ -7505,12 +7587,18 @@ var CollectionController = class {
7505
7587
  if (!data.id) {
7506
7588
  data = { ...data, id: generateDocumentId(this.collection) };
7507
7589
  }
7590
+ const beforeCommit = this.buildBeforeCommit(c, "create", user);
7508
7591
  const doc = this.collection.workflow ? (await createWorkflowDocument({
7509
7592
  config,
7510
7593
  collection: this.collection,
7511
7594
  data,
7512
- user
7513
- })).doc : await db.create({ collection: this.collection.slug, data });
7595
+ user,
7596
+ beforeCommit
7597
+ })).doc : await this.persistInTransaction(
7598
+ db,
7599
+ beforeCommit,
7600
+ (writer) => writer.create({ collection: this.collection.slug, data })
7601
+ );
7514
7602
  if (this.collection.audit && db) {
7515
7603
  AuditService.log(db, {
7516
7604
  operation: "create",
@@ -7744,6 +7832,36 @@ var CollectionController = class {
7744
7832
  const body = await c.req.json();
7745
7833
  return this.performUpdate(c, id, body);
7746
7834
  }
7835
+ /**
7836
+ * Builds the callback that runs the collection's `beforeCommit` hooks inside
7837
+ * the write transaction, or `undefined` when the collection has none.
7838
+ */
7839
+ buildBeforeCommit(c, operation, user, previousDoc) {
7840
+ const hooks = this.collection.hooks?.beforeCommit;
7841
+ if (!hooks?.length) return void 0;
7842
+ return async (tx, doc) => {
7843
+ for (const hook of hooks) {
7844
+ await hook({ doc, previousDoc, req: this.toHookRequestContext(c), user, operation, tx });
7845
+ }
7846
+ };
7847
+ }
7848
+ /**
7849
+ * Runs a single write. When `beforeCommit` hooks exist the write and the
7850
+ * hooks share one transaction, so a throwing hook rolls the write back.
7851
+ */
7852
+ async persistInTransaction(db, beforeCommit, write) {
7853
+ if (!beforeCommit) return write(db);
7854
+ if (!db.transaction) {
7855
+ throw new Error(
7856
+ `Collection "${this.collection.slug}" defines beforeCommit hooks but the configured database adapter does not support transactions.`
7857
+ );
7858
+ }
7859
+ return db.transaction(async (tx) => {
7860
+ const doc = await write(tx);
7861
+ await beforeCommit(tx, doc);
7862
+ return doc;
7863
+ });
7864
+ }
7747
7865
  /**
7748
7866
  * Core update pipeline shared by PATCH requests and operational actions:
7749
7867
  * auth-field stripping, timestamps, access enforcement, field write access,
@@ -7785,6 +7903,7 @@ var CollectionController = class {
7785
7903
  403
7786
7904
  );
7787
7905
  }
7906
+ data = normalizeMoneyFields(this.collection.fields, data);
7788
7907
  let before = null;
7789
7908
  if (this.collection.audit) {
7790
7909
  before = originalDoc;
@@ -7818,14 +7937,21 @@ var CollectionController = class {
7818
7937
  surface: "collection.beforeChange",
7819
7938
  path: `collection:${this.collection.slug}.hooks.beforeChange`
7820
7939
  });
7940
+ assertImmutableFieldsUnchanged(this.collection.fields, data, originalDoc);
7941
+ const beforeCommit = this.buildBeforeCommit(c, "update", user, originalDoc);
7821
7942
  const doc = this.collection.workflow ? (await saveWorkflowDraft({
7822
7943
  config,
7823
7944
  collection: this.collection,
7824
7945
  id,
7825
7946
  originalDoc,
7826
7947
  data,
7827
- user
7828
- })).doc : await db.update({ collection: this.collection.slug, id, data });
7948
+ user,
7949
+ beforeCommit
7950
+ })).doc : await this.persistInTransaction(
7951
+ db,
7952
+ beforeCommit,
7953
+ (writer) => writer.update({ collection: this.collection.slug, id, data })
7954
+ );
7829
7955
  if (this.collection.audit && db) {
7830
7956
  AuditService.log(db, {
7831
7957
  operation: "update",
@@ -8052,6 +8178,7 @@ var CollectionController = class {
8052
8178
  transitionName,
8053
8179
  expectedRevision: body.expectedRevision,
8054
8180
  comment: body.comment,
8181
+ input: body.input,
8055
8182
  user: c.get("user"),
8056
8183
  req: { query: c.req.query(), headers: c.req.header(), raw: c.req.raw }
8057
8184
  });
@@ -9383,6 +9510,20 @@ var MediaFolderController = class {
9383
9510
  }
9384
9511
  };
9385
9512
 
9513
+ // src/utils/admin-url.ts
9514
+ var DEFAULT_ADMIN_PATH = "/admin";
9515
+ function resolveAdminUrl(c, config, clientUrl) {
9516
+ const configured = config.admin?.adminUrl?.trim() || process.env.DYRECTED_ADMIN_URL?.trim() || (typeof clientUrl === "string" ? clientUrl.trim() : "") || DEFAULT_ADMIN_PATH;
9517
+ try {
9518
+ return new URL(configured, c.req.url).toString();
9519
+ } catch {
9520
+ return configured;
9521
+ }
9522
+ }
9523
+ function appendQueryParam(url, key, value) {
9524
+ return `${url}${url.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(value)}`;
9525
+ }
9526
+
9386
9527
  // src/controllers/auth.controller.ts
9387
9528
  import { randomBytes as randomBytes3 } from "crypto";
9388
9529
 
@@ -10058,8 +10199,7 @@ var AuthController = class {
10058
10199
  },
10059
10200
  "1h"
10060
10201
  );
10061
- const resetUrl = body?.resetUrl;
10062
- const url = resetUrl ? `${resetUrl}${resetUrl.includes("?") ? "&" : "?"}token=${encodeURIComponent(resetToken)}` : void 0;
10202
+ const url = appendQueryParam(resolveAdminUrl(c, config, body?.resetUrl), "token", resetToken);
10063
10203
  try {
10064
10204
  const { subject, html } = buildResetPasswordEmail(config, {
10065
10205
  token: resetToken,
@@ -10191,8 +10331,7 @@ var AuthController = class {
10191
10331
  },
10192
10332
  "7d"
10193
10333
  );
10194
- const inviteUrl = body?.inviteUrl;
10195
- const url = inviteUrl ? `${inviteUrl}${inviteUrl.includes("?") ? "&" : "?"}inviteToken=${encodeURIComponent(inviteToken)}` : void 0;
10334
+ const url = appendQueryParam(resolveAdminUrl(c, config, body?.inviteUrl), "inviteToken", inviteToken);
10196
10335
  try {
10197
10336
  const { subject, html } = buildInviteEmail(config, {
10198
10337
  token: inviteToken,
@@ -12471,7 +12610,12 @@ function generateOpenApi(config) {
12471
12610
  type: "object",
12472
12611
  properties: {
12473
12612
  expectedRevision: { type: "integer", minimum: 1 },
12474
- comment: { type: "string" }
12613
+ comment: { type: "string" },
12614
+ input: {
12615
+ type: "object",
12616
+ additionalProperties: true,
12617
+ description: "Extra data passed to the transition's onTransition handler."
12618
+ }
12475
12619
  }
12476
12620
  },
12477
12621
  WorkflowHistoryEntry: {
@@ -13634,6 +13778,9 @@ function fieldToSchema(field) {
13634
13778
  case "number":
13635
13779
  schema = { type: "number" };
13636
13780
  break;
13781
+ case "money":
13782
+ schema = { type: "integer", description: "Amount in minor units (for example kobo or cents)." };
13783
+ break;
13637
13784
  case "boolean":
13638
13785
  schema = { type: "boolean" };
13639
13786
  break;
@@ -14453,6 +14600,30 @@ function serializeDetailForApi(detail) {
14453
14600
  if (!detail || !Array.isArray(detail)) return void 0;
14454
14601
  return detail.map(serializeDetailItemForApi);
14455
14602
  }
14603
+ async function serializeActionsForApi(actions, serializeAccess) {
14604
+ const serializeAccessConfig = async (access) => {
14605
+ if (!access) return void 0;
14606
+ const serialized = {};
14607
+ for (const key of ["read", "create", "update", "delete"]) {
14608
+ if (access[key] !== void 0) serialized[key] = await serializeAccess(access[key]);
14609
+ }
14610
+ return serialized;
14611
+ };
14612
+ return Promise.all(
14613
+ (actions || []).map(async (action) => ({
14614
+ name: action.name,
14615
+ label: action.label,
14616
+ icon: action.icon,
14617
+ type: action.type ?? "row",
14618
+ confirm: action.confirm,
14619
+ submitLabel: action.submitLabel,
14620
+ fields: action.fields?.map(serializeFieldForApi),
14621
+ mutation: action.mutation,
14622
+ // Self-hosted handlers are intentionally omitted: they never leave the server.
14623
+ access: action.access ? await serializeAccessConfig(action.access) : void 0
14624
+ }))
14625
+ );
14626
+ }
14456
14627
  async function serializeViewForApi(view, serializeAccess) {
14457
14628
  if (!view) return view;
14458
14629
  const serializeAccessConfig = async (access) => {
@@ -14479,20 +14650,7 @@ async function serializeViewForApi(view, serializeAccess) {
14479
14650
  features: view.features,
14480
14651
  actionOrder: view.actionOrder,
14481
14652
  components: view.components,
14482
- actions: await Promise.all(
14483
- (view.actions || []).map(async (action) => ({
14484
- name: action.name,
14485
- label: action.label,
14486
- icon: action.icon,
14487
- type: action.type ?? "row",
14488
- confirm: action.confirm,
14489
- submitLabel: action.submitLabel,
14490
- fields: action.fields?.map(serializeFieldForApi),
14491
- mutation: action.mutation,
14492
- // Self-hosted handlers are intentionally omitted: they never leave the server.
14493
- access: action.access ? await serializeAccessConfig(action.access) : void 0
14494
- }))
14495
- ),
14653
+ actions: await serializeActionsForApi(view.actions, serializeAccess),
14496
14654
  access: view.access ? await serializeAccessConfig(view.access) : void 0
14497
14655
  };
14498
14656
  }
@@ -14574,6 +14732,7 @@ function registerRoutes(app, config) {
14574
14732
  drafts: !!col.drafts,
14575
14733
  defaultView: col.defaultView ?? col.admin?.defaultView,
14576
14734
  views: await Promise.all((col.views || []).map((view) => serializeViewForApi(view, serializeAccess))),
14735
+ actions: await serializeActionsForApi(col.actions, serializeAccess),
14577
14736
  admin: col.admin,
14578
14737
  detail: serializeDetailForApi(col.detail),
14579
14738
  workflow: col.workflow ? {
@@ -15454,6 +15613,31 @@ function registerRoutes(app, config) {
15454
15613
  });
15455
15614
  }
15456
15615
 
15616
+ // src/tasks.ts
15617
+ var TASK_LOCKS_COLLECTION = "__task_locks";
15618
+ var TASK_LOCKS_COLLECTION_CONFIG = {
15619
+ slug: TASK_LOCKS_COLLECTION,
15620
+ labels: { singular: "Task lock", plural: "Task locks" },
15621
+ fields: [
15622
+ { name: "name", type: "text", required: true },
15623
+ // Numeric and promoted so the lock compare-and-set is a real column comparison.
15624
+ { name: "lockedUntil", type: "number", required: true, promoted: true },
15625
+ { name: "lockedBy", type: "text" },
15626
+ { name: "lastScheduledFor", type: "date" },
15627
+ { name: "lastStartedAt", type: "date" },
15628
+ { name: "lastFinishedAt", type: "date" },
15629
+ { name: "lastStatus", type: "text" },
15630
+ { name: "lastError", type: "textarea" }
15631
+ ],
15632
+ access: {
15633
+ read: ({ user }) => !!user?.roles?.includes("admin"),
15634
+ create: () => false,
15635
+ update: () => false,
15636
+ delete: () => false
15637
+ },
15638
+ admin: { hidden: true }
15639
+ };
15640
+
15457
15641
  // src/utils/config.ts
15458
15642
  var AUDIT_COLLECTION_SLUG = "__audit";
15459
15643
  var SYSTEM_FIELDS = [
@@ -15605,6 +15789,7 @@ function normalizeConfig(config) {
15605
15789
  const globals = schemaAwareConfig?.globals || [];
15606
15790
  const needsAudit = collections.some((col) => col.audit);
15607
15791
  const needsWorkflow = collections.some((col) => col.workflow || col.drafts);
15792
+ const needsTasks = (config.tasks?.length ?? 0) > 0;
15608
15793
  const needsAuthSessions = collections.some((col) => !!col.auth);
15609
15794
  const adminAuthCollectionSlug = getAdminAuthCollection({
15610
15795
  collections,
@@ -15837,6 +16022,9 @@ function normalizeConfig(config) {
15837
16022
  if (needsWorkflow && !normalizedCollections.some((col) => col.slug === LIFECYCLE_EVENTS_COLLECTION)) {
15838
16023
  systemCollections.push(LIFECYCLE_EVENTS_COLLECTION_CONFIG);
15839
16024
  }
16025
+ if (needsTasks && !normalizedCollections.some((col) => col.slug === TASK_LOCKS_COLLECTION)) {
16026
+ systemCollections.push(TASK_LOCKS_COLLECTION_CONFIG);
16027
+ }
15840
16028
  if (needsAuthSessions && !normalizedCollections.some((col) => col.slug === AUTH_SESSIONS_COLLECTION)) {
15841
16029
  systemCollections.push(AUTH_SESSIONS_COLLECTION_CONFIG);
15842
16030
  }
@@ -16206,6 +16394,7 @@ export {
16206
16394
  revokeAllAuthSessions,
16207
16395
  revokeAuthSession,
16208
16396
  sendEmail,
16397
+ serializeActionsForApi,
16209
16398
  serializeDetailForApi,
16210
16399
  serializeViewForApi,
16211
16400
  shouldSampleRequest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dyrected/core",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",