@dyrected/core 2.5.29 → 2.5.32

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.cjs CHANGED
@@ -62,6 +62,10 @@ function sanitizeWhereClause(where, fields) {
62
62
  }
63
63
  continue;
64
64
  }
65
+ if (key === "id") {
66
+ result[key] = value;
67
+ continue;
68
+ }
65
69
  const fieldDef = fieldMap.get(key);
66
70
  if (!fieldDef) continue;
67
71
  if (fieldDef.admin?.filterable === false) continue;
@@ -531,6 +535,243 @@ function createReadonlyDb(db) {
531
535
  });
532
536
  }
533
537
 
538
+ // src/auth/jexl.ts
539
+ var import_jexl = __toESM(require("jexl"), 1);
540
+ async function evaluateAccess(expression, context) {
541
+ if (expression === void 0 || expression === null) return false;
542
+ if (typeof expression === "boolean") return expression;
543
+ try {
544
+ const result = await import_jexl.default.eval(expression, context);
545
+ return !!result;
546
+ } catch (err) {
547
+ console.error("[dyrected/core] Jexl evaluation failed:", err);
548
+ return false;
549
+ }
550
+ }
551
+
552
+ // src/workflows.ts
553
+ var WORKFLOW_HISTORY_COLLECTION = "__workflow_history";
554
+ var LIFECYCLE_EVENTS_COLLECTION = "__lifecycle_events";
555
+ function publicMetadata(meta) {
556
+ const { availableTransitions: _availableTransitions, ...safe } = meta;
557
+ return safe;
558
+ }
559
+ function workflowCapabilities(workflow, user) {
560
+ const capabilities = new Set(Array.isArray(user?.capabilities) ? user.capabilities : []);
561
+ const roles = Array.isArray(user?.roles) ? user.roles : [];
562
+ for (const mapping of workflow.roles ?? []) {
563
+ if (roles.includes(mapping.role)) mapping.capabilities.forEach((capability) => capabilities.add(capability));
564
+ }
565
+ return capabilities;
566
+ }
567
+ function canViewWorkflowDraft(workflow, user) {
568
+ if (!user) return false;
569
+ const capabilities = workflowCapabilities(workflow, user);
570
+ if (capabilities.has("entry.edit")) return true;
571
+ return workflow.transitions.some(
572
+ (transition) => (transition.requiredCapabilities ?? []).some((capability) => capabilities.has(capability))
573
+ );
574
+ }
575
+ function availableWorkflowTransitions(workflow, state, user) {
576
+ const capabilities = workflowCapabilities(workflow, user);
577
+ return workflow.transitions.filter((transition) => {
578
+ const from = Array.isArray(transition.from) ? transition.from : [transition.from];
579
+ return from.includes(state) && (transition.requiredCapabilities ?? []).every((item) => capabilities.has(item));
580
+ });
581
+ }
582
+ function initializeWorkflowDocument(data, workflow) {
583
+ return {
584
+ ...data,
585
+ __workflow: { state: workflow.initialState, revision: 1 }
586
+ };
587
+ }
588
+ function materializeWorkflowDocument(doc, workflow, user) {
589
+ const meta = doc.__workflow;
590
+ if (!meta) return doc;
591
+ const { __published, __workflow, ...working } = doc;
592
+ if (!canViewWorkflowDraft(workflow, user)) {
593
+ if (!__published || typeof __published !== "object") return null;
594
+ return { id: doc.id, ...__published, _workflow: publicMetadata(meta) };
595
+ }
596
+ return {
597
+ ...working,
598
+ _workflow: {
599
+ ...meta,
600
+ availableTransitions: availableWorkflowTransitions(workflow, meta.state, user).map((item) => item.name)
601
+ }
602
+ };
603
+ }
604
+ function eventId() {
605
+ return globalThis.crypto?.randomUUID?.() ?? `evt_${Date.now()}_${Math.random().toString(36).slice(2)}`;
606
+ }
607
+ function createLifecycleEvent(args) {
608
+ return {
609
+ id: eventId(),
610
+ name: args.name,
611
+ collection: args.collection,
612
+ documentId: args.documentId,
613
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
614
+ actorId: args.actorId,
615
+ payload: args.payload,
616
+ attempts: 0,
617
+ status: "pending"
618
+ };
619
+ }
620
+ async function persistEvent(db, event) {
621
+ await db.create({ collection: LIFECYCLE_EVENTS_COLLECTION, data: event });
622
+ }
623
+ async function dispatchLifecycleEvent(config, event) {
624
+ const db = config.db;
625
+ if (!db || !config.events?.handlers.length) return;
626
+ const maxAttempts = config.events.maxAttempts ?? 8;
627
+ const retryDelayMs = config.events.retryDelayMs ?? 1e3;
628
+ const attempts = event.attempts + 1;
629
+ try {
630
+ await db.update({ collection: LIFECYCLE_EVENTS_COLLECTION, id: event.id, data: { status: "processing", attempts } });
631
+ for (const handler of config.events.handlers) await handler({ ...event, status: "processing", attempts });
632
+ await db.update({
633
+ collection: LIFECYCLE_EVENTS_COLLECTION,
634
+ id: event.id,
635
+ data: { status: "delivered", attempts, deliveredAt: (/* @__PURE__ */ new Date()).toISOString(), lastError: null }
636
+ });
637
+ } catch (error) {
638
+ const exhausted = attempts >= maxAttempts;
639
+ await db.update({
640
+ collection: LIFECYCLE_EVENTS_COLLECTION,
641
+ id: event.id,
642
+ data: {
643
+ status: "failed",
644
+ attempts,
645
+ lastError: error instanceof Error ? error.message : String(error),
646
+ nextAttemptAt: exhausted ? null : new Date(Date.now() + retryDelayMs * 2 ** (attempts - 1)).toISOString()
647
+ }
648
+ });
649
+ }
650
+ }
651
+ async function saveWorkflowDraft(args) {
652
+ const { config, collection, id, originalDoc, data, user } = args;
653
+ const db = config.db;
654
+ const workflow = collection.workflow;
655
+ if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
656
+ const previous = originalDoc.__workflow;
657
+ const event = createLifecycleEvent({
658
+ name: "revision.created",
659
+ collection: collection.slug,
660
+ documentId: id,
661
+ actorId: user?.sub,
662
+ payload: { revision: previous.revision + 1, previousRevision: previous.revision }
663
+ });
664
+ const doc = await db.transaction(async (tx) => {
665
+ const nextMeta = {
666
+ ...previous,
667
+ state: originalDoc.__published ? workflow.draftState ?? workflow.initialState : previous.state,
668
+ revision: previous.revision + 1
669
+ };
670
+ const updated = await tx.update({ collection: collection.slug, id, data: { ...data, __workflow: nextMeta } });
671
+ await persistEvent(tx, event);
672
+ return updated;
673
+ });
674
+ void dispatchLifecycleEvent(config, event);
675
+ return { doc, event };
676
+ }
677
+ async function createWorkflowDocument(args) {
678
+ const { config, collection, data, user } = args;
679
+ const db = config.db;
680
+ if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
681
+ let event;
682
+ const doc = await db.transaction(async (tx) => {
683
+ const created = await tx.create({ collection: collection.slug, data });
684
+ event = createLifecycleEvent({
685
+ name: "revision.created",
686
+ collection: collection.slug,
687
+ documentId: created.id,
688
+ actorId: user?.sub,
689
+ payload: { revision: 1, previousRevision: null }
690
+ });
691
+ await persistEvent(tx, event);
692
+ return created;
693
+ });
694
+ void dispatchLifecycleEvent(config, event);
695
+ return { doc, event };
696
+ }
697
+ async function transitionWorkflow(args) {
698
+ const { config, collection, id, transitionName, expectedRevision, comment, user, req } = args;
699
+ const db = config.db;
700
+ const workflow = collection.workflow;
701
+ if (!db.transaction) throw new Error(`The configured database adapter does not support workflow transactions.`);
702
+ const original = await db.findOne({ collection: collection.slug, id });
703
+ if (!original) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
704
+ const meta = original.__workflow;
705
+ if (!meta) throw Object.assign(new Error("Entry has no workflow metadata"), { statusCode: 409 });
706
+ if (expectedRevision !== void 0 && expectedRevision !== meta.revision) {
707
+ throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), { statusCode: 409 });
708
+ }
709
+ const transition = workflow.transitions.find((item) => item.name === transitionName);
710
+ const fromStates = transition ? Array.isArray(transition.from) ? transition.from : [transition.from] : [];
711
+ if (!transition || !fromStates.includes(meta.state)) {
712
+ throw Object.assign(new Error(`Transition "${transitionName}" is not valid from "${meta.state}".`), { statusCode: 409 });
713
+ }
714
+ if (!availableWorkflowTransitions(workflow, meta.state, user).some((item) => item.name === transition.name)) {
715
+ throw Object.assign(new Error(`You do not have permission to perform "${transition.label}".`), { statusCode: 403 });
716
+ }
717
+ if (transition.requireComment && !comment?.trim()) {
718
+ throw Object.assign(new Error(`A comment is required for "${transition.label}".`), { statusCode: 400 });
719
+ }
720
+ const hookContext = { transition, from: meta.state, to: transition.to, doc: original, user, comment, req, db };
721
+ for (const hook of workflow.hooks?.beforeTransition ?? []) await hook(hookContext);
722
+ const events = [createLifecycleEvent({
723
+ name: "workflow.transitioned",
724
+ collection: collection.slug,
725
+ documentId: id,
726
+ actorId: user?.sub,
727
+ payload: { transition: transition.name, from: meta.state, to: transition.to, revision: meta.revision, comment }
728
+ })];
729
+ const targetState = workflow.states.find((state) => state.name === transition.to);
730
+ if (targetState.published) {
731
+ events.push(createLifecycleEvent({ name: "entry.published", collection: collection.slug, documentId: id, actorId: user?.sub, payload: { revision: meta.revision } }));
732
+ } else if (transition.unpublish) {
733
+ events.push(createLifecycleEvent({ name: "entry.unpublished", collection: collection.slug, documentId: id, actorId: user?.sub, payload: { revision: meta.publishedRevision } }));
734
+ }
735
+ const updated = await db.transaction(async (tx) => {
736
+ const locked = await tx.findOne({ collection: collection.slug, id });
737
+ if (!locked) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
738
+ const lockedMeta = locked.__workflow;
739
+ if (lockedMeta.revision !== meta.revision || lockedMeta.state !== meta.state || expectedRevision !== void 0 && lockedMeta.revision !== expectedRevision) {
740
+ throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), { statusCode: 409 });
741
+ }
742
+ const now = (/* @__PURE__ */ new Date()).toISOString();
743
+ const { __published: _published, __workflow: _workflow, id: _id, ...working } = locked;
744
+ const nextMeta = {
745
+ ...lockedMeta,
746
+ state: transition.to,
747
+ ...targetState.published ? { publishedRevision: lockedMeta.revision, publishedAt: now, publishedBy: user?.sub } : {},
748
+ ...transition.unpublish ? { publishedRevision: void 0, publishedAt: void 0, publishedBy: void 0 } : {}
749
+ };
750
+ const data = { __workflow: nextMeta };
751
+ if (targetState.published) data.__published = working;
752
+ if (transition.unpublish) data.__published = null;
753
+ const next = await tx.update({ collection: collection.slug, id, data });
754
+ await tx.create({
755
+ collection: WORKFLOW_HISTORY_COLLECTION,
756
+ data: { collection: collection.slug, documentId: id, transition: transition.name, from: lockedMeta.state, to: transition.to, revision: lockedMeta.revision, comment: comment ?? null, actorId: user?.sub ?? null, createdAt: now }
757
+ });
758
+ for (const event of events) await persistEvent(tx, event);
759
+ if (collection.audit) {
760
+ await tx.create({ collection: "__audit", data: { collection: collection.slug, documentId: id, operation: "workflow.transition", user: user?.sub ?? null, timestamp: now, changes: JSON.stringify({ transition: transition.name, from: lockedMeta.state, to: transition.to, revision: lockedMeta.revision }) } });
761
+ }
762
+ return next;
763
+ });
764
+ for (const event of events) void dispatchLifecycleEvent(config, event);
765
+ for (const hook of workflow.hooks?.afterTransition ?? []) {
766
+ try {
767
+ await hook({ ...hookContext, doc: updated, event: events[0] });
768
+ } catch (error) {
769
+ console.error("[dyrected/workflow] afterTransition hook failed:", error);
770
+ }
771
+ }
772
+ return updated;
773
+ }
774
+
534
775
  // src/controllers/collection.controller.ts
535
776
  var CollectionController = class {
536
777
  collection;
@@ -575,6 +816,9 @@ var CollectionController = class {
575
816
  if (beforeReadResult !== void 0) {
576
817
  where = beforeReadResult;
577
818
  }
819
+ if (this.collection.workflow && !canViewWorkflowDraft(this.collection.workflow, user)) {
820
+ where = where ? { AND: [where, { __published: { exists: true } }] } : { __published: { exists: true } };
821
+ }
578
822
  let result = await db.find({
579
823
  collection: this.collection.slug,
580
824
  limit,
@@ -595,7 +839,7 @@ var CollectionController = class {
595
839
  where
596
840
  });
597
841
  }
598
- result.docs = result.docs.map((doc) => DefaultsService.apply(this.collection.fields, doc));
842
+ result.docs = result.docs.map((doc) => this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc).filter((doc) => doc !== null).map((doc) => DefaultsService.apply(this.collection.fields, doc));
599
843
  const processedDocs = [];
600
844
  for (const doc of result.docs) {
601
845
  const docWithCollectionHooks = await runCollectionHooks(this.collection.hooks?.afterRead, {
@@ -623,8 +867,12 @@ var CollectionController = class {
623
867
  const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 10;
624
868
  const user = c.get("user");
625
869
  if (!id) return c.json({ message: "Missing ID" }, 400);
626
- const doc = await db.findOne({ collection: this.collection.slug, id });
870
+ let doc = await db.findOne({ collection: this.collection.slug, id });
627
871
  if (!doc) return c.json({ message: "Not Found" }, 404);
872
+ if (this.collection.workflow) {
873
+ doc = materializeWorkflowDocument(doc, this.collection.workflow, user);
874
+ if (!doc) return c.json({ message: "Not Found" }, 404);
875
+ }
628
876
  const docWithDefaults = DefaultsService.apply(this.collection.fields, doc);
629
877
  const docWithCollectionHooks = await runCollectionHooks(this.collection.hooks?.afterRead, {
630
878
  doc: docWithDefaults,
@@ -664,6 +912,9 @@ var CollectionController = class {
664
912
  createdBy: user?.sub ?? null,
665
913
  updatedBy: user?.sub ?? null
666
914
  };
915
+ if (this.collection.workflow) {
916
+ data = initializeWorkflowDocument(data, this.collection.workflow);
917
+ }
667
918
  if (this.collection.auth && data.password) {
668
919
  data.password = await hashPassword(data.password);
669
920
  }
@@ -675,7 +926,7 @@ var CollectionController = class {
675
926
  operation: "create",
676
927
  db: readonlyDb
677
928
  });
678
- const doc = await db.create({ collection: this.collection.slug, data });
929
+ const doc = this.collection.workflow ? (await createWorkflowDocument({ config, collection: this.collection, data, user })).doc : await db.create({ collection: this.collection.slug, data });
679
930
  if (this.collection.audit && db) {
680
931
  AuditService.log(db, {
681
932
  operation: "create",
@@ -693,8 +944,9 @@ var CollectionController = class {
693
944
  operation: "create",
694
945
  db
695
946
  }, { isolated: true });
947
+ const responseDoc = this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc;
696
948
  const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
697
- doc,
949
+ doc: responseDoc,
698
950
  req: c.req,
699
951
  user,
700
952
  db: readonlyDb
@@ -757,8 +1009,9 @@ var CollectionController = class {
757
1009
  operation: "create",
758
1010
  db
759
1011
  }, { isolated: true });
1012
+ const responseDoc = this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc;
760
1013
  const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
761
- doc,
1014
+ doc: responseDoc,
762
1015
  req: c.req,
763
1016
  user,
764
1017
  db: readonlyDb
@@ -800,7 +1053,7 @@ var CollectionController = class {
800
1053
  operation: "update",
801
1054
  db: readonlyDb
802
1055
  });
803
- const doc = await db.update({ collection: this.collection.slug, id, data });
1056
+ const doc = this.collection.workflow ? (await saveWorkflowDraft({ config, collection: this.collection, id, originalDoc, data, user })).doc : await db.update({ collection: this.collection.slug, id, data });
804
1057
  if (this.collection.audit && db) {
805
1058
  AuditService.log(db, {
806
1059
  operation: "update",
@@ -828,6 +1081,61 @@ var CollectionController = class {
828
1081
  const finalDoc = await executeFieldAfterRead(this.collection.fields, readDoc, user, readonlyDb);
829
1082
  return c.json(finalDoc);
830
1083
  }
1084
+ async transition(c) {
1085
+ const config = c.get("config");
1086
+ if (!config.db) return c.json({ message: "Database not configured" }, 500);
1087
+ if (!this.collection.workflow) return c.json({ message: "Workflows are not enabled for this collection" }, 404);
1088
+ const id = c.req.param("id");
1089
+ const transitionName = c.req.param("transition");
1090
+ const body = await c.req.json().catch(() => ({}));
1091
+ try {
1092
+ const doc = await transitionWorkflow({
1093
+ config,
1094
+ collection: this.collection,
1095
+ id,
1096
+ transitionName,
1097
+ expectedRevision: body.expectedRevision,
1098
+ comment: body.comment,
1099
+ user: c.get("user"),
1100
+ req: { query: c.req.query(), headers: c.req.header(), raw: c.req.raw }
1101
+ });
1102
+ return c.json(materializeWorkflowDocument(doc, this.collection.workflow, c.get("user")));
1103
+ } catch (error) {
1104
+ const status = typeof error.statusCode === "number" ? error.statusCode : 500;
1105
+ return c.json({ error: true, message: error instanceof Error ? error.message : String(error) }, status);
1106
+ }
1107
+ }
1108
+ async workflowHistory(c) {
1109
+ const config = c.get("config");
1110
+ if (!config.db) return c.json({ message: "Database not configured" }, 500);
1111
+ if (!this.collection.workflow) return c.json({ message: "Workflows are not enabled for this collection" }, 404);
1112
+ const documentId = c.req.param("id");
1113
+ if (!documentId) return c.json({ message: "Missing ID" }, 400);
1114
+ const document = await config.db.findOne({ collection: this.collection.slug, id: documentId });
1115
+ if (!document) return c.json({ message: "Not Found" }, 404);
1116
+ const readAccess = this.collection.access?.read;
1117
+ if (readAccess !== void 0 && readAccess !== null) {
1118
+ const args = { user: c.get("user"), req: c.req, doc: document };
1119
+ const result2 = typeof readAccess === "function" ? await readAccess(args) : await evaluateAccess(readAccess, args);
1120
+ let allowed = result2 === true;
1121
+ if (result2 && typeof result2 === "object") {
1122
+ const match = await config.db.find({
1123
+ collection: this.collection.slug,
1124
+ where: { AND: [{ id: { equals: documentId } }, result2] },
1125
+ limit: 1
1126
+ });
1127
+ allowed = match.total > 0;
1128
+ }
1129
+ if (!allowed) return c.json({ error: true, message: `Access denied: read on ${this.collection.slug}` }, 403);
1130
+ }
1131
+ const result = await config.db.find({
1132
+ collection: WORKFLOW_HISTORY_COLLECTION,
1133
+ where: { collection: { equals: this.collection.slug }, documentId: { equals: documentId } },
1134
+ sort: "-createdAt",
1135
+ limit: Math.min(Number(c.req.query("limit")) || 50, 100)
1136
+ });
1137
+ return c.json(result);
1138
+ }
831
1139
  /**
832
1140
  * POST /api/collections/:slug/:id/change-password
833
1141
  *
@@ -1295,6 +1603,96 @@ async function verifyCollectionToken(token) {
1295
1603
  return payload;
1296
1604
  }
1297
1605
 
1606
+ // src/services/email-template.ts
1607
+ var emailTokens = {
1608
+ colors: {
1609
+ canvas: "#f6f7f2",
1610
+ surface: "#ffffff",
1611
+ text: "#171717",
1612
+ muted: "#62665b",
1613
+ subtle: "#8a8f82",
1614
+ border: "#dde0d7",
1615
+ accent: "#b6ff2e",
1616
+ code: "#f1f3ec",
1617
+ dangerSurface: "#fff2f0",
1618
+ dangerBorder: "#ffc9c2",
1619
+ dangerText: "#9f251b"
1620
+ },
1621
+ font: "Arial, 'Helvetica Neue', Helvetica, sans-serif",
1622
+ mono: "'Courier New', Courier, monospace",
1623
+ radius: { card: "12px", control: "6px" },
1624
+ width: "600px"
1625
+ };
1626
+ function escapeHtml(value) {
1627
+ return value.replace(/[&<>'"]/g, (character) => ({
1628
+ "&": "&amp;",
1629
+ "<": "&lt;",
1630
+ ">": "&gt;",
1631
+ "'": "&#39;",
1632
+ '"': "&quot;"
1633
+ })[character]);
1634
+ }
1635
+ function safeHttpUrl(value) {
1636
+ try {
1637
+ const url = new URL(value);
1638
+ return url.protocol === "https:" || url.protocol === "http:" ? escapeHtml(url.toString()) : void 0;
1639
+ } catch {
1640
+ return void 0;
1641
+ }
1642
+ }
1643
+ function heading(content) {
1644
+ return `<h1 style="margin:0;font-family:${emailTokens.font};font-size:24px;line-height:1.25;font-weight:700;color:${emailTokens.colors.text}">${escapeHtml(content)}</h1>`;
1645
+ }
1646
+ function paragraph(content, margin = "0 0 16px") {
1647
+ return `<p style="margin:${margin};font-family:${emailTokens.font};font-size:15px;line-height:1.6;color:${emailTokens.colors.muted}">${escapeHtml(content)}</p>`;
1648
+ }
1649
+ function sectionLabel(content) {
1650
+ return `<p style="margin:0 0 8px;font-family:${emailTokens.font};font-size:11px;line-height:1.4;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:${emailTokens.colors.subtle}">${escapeHtml(content)}</p>`;
1651
+ }
1652
+ function divider() {
1653
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr><td style="height:1px;background:${emailTokens.colors.border};font-size:0;line-height:0">&nbsp;</td></tr></table>`;
1654
+ }
1655
+ function spacer(height = 16) {
1656
+ return table(row("&nbsp;", `height:${height}px;font-size:0;line-height:0`));
1657
+ }
1658
+ function row(content, cellStyle = "") {
1659
+ return `<tr><td style="${cellStyle}">${content}</td></tr>`;
1660
+ }
1661
+ function table(content, style = "width:100%") {
1662
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="${style}">${content}</table>`;
1663
+ }
1664
+ function detailBox(content, monospace = false) {
1665
+ const font = monospace ? emailTokens.mono : emailTokens.font;
1666
+ return table(row(escapeHtml(content), `padding:14px 16px;font-family:${font};font-size:13px;line-height:1.5;font-weight:${monospace ? "400" : "700"};color:${emailTokens.colors.text};word-break:break-all`), `width:100%;background:${emailTokens.colors.code};border-radius:${emailTokens.radius.control}`);
1667
+ }
1668
+ function ctaButton(label, href) {
1669
+ const safeHref = safeHttpUrl(href);
1670
+ if (!safeHref) return "";
1671
+ return table(row(
1672
+ `<a href="${safeHref}" style="display:inline-block;padding:13px 22px;border-radius:${emailTokens.radius.control};background:${emailTokens.colors.accent};font-family:${emailTokens.font};font-size:14px;line-height:1.2;font-weight:700;color:${emailTokens.colors.text};text-decoration:none">${escapeHtml(label)}</a>`,
1673
+ "padding:8px 0 24px"
1674
+ ), "width:auto");
1675
+ }
1676
+ function alertBox(content) {
1677
+ return table(row(escapeHtml(content), `padding:13px 16px;font-family:${emailTokens.font};font-size:13px;line-height:1.5;color:${emailTokens.colors.dangerText}`), `width:100%;background:${emailTokens.colors.dangerSurface};border:1px solid ${emailTokens.colors.dangerBorder};border-radius:${emailTokens.radius.control}`);
1678
+ }
1679
+ function layout({ preheader, title, content, footer }) {
1680
+ return `<!doctype html>
1681
+ <html lang="en">
1682
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
1683
+ <body style="margin:0;padding:0;background:${emailTokens.colors.canvas}">
1684
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent">${escapeHtml(preheader)}</div>
1685
+ ${table(row(
1686
+ table(
1687
+ row("&nbsp;", `height:5px;background:${emailTokens.colors.accent};font-size:0;line-height:0`) + row(`${sectionLabel("Dyrected")}${heading(title)}`, "padding:30px 32px 24px") + row(content, "padding:0 32px 32px") + row(`${divider()}${paragraph(footer, "20px 0 6px")}${paragraph("Privacy: this message contains account-related information; please avoid forwarding it.", "0")}`, "padding:0 32px 28px"),
1688
+ `width:100%;max-width:${emailTokens.width};background:${emailTokens.colors.surface};border:1px solid ${emailTokens.colors.border};border-radius:${emailTokens.radius.card};overflow:hidden`
1689
+ ),
1690
+ "padding:32px 12px"
1691
+ ), `width:100%;background:${emailTokens.colors.canvas}`)}
1692
+ </body>
1693
+ </html>`;
1694
+ }
1695
+
1298
1696
  // src/services/email.service.ts
1299
1697
  var _devSend = null;
1300
1698
  var _devSendPromise = null;
@@ -1338,77 +1736,24 @@ function buildWelcomeEmail(config, args) {
1338
1736
  const custom = config.email?.templates?.welcome?.(args);
1339
1737
  return {
1340
1738
  subject: custom?.subject ?? "Welcome \u2014 your account is ready",
1341
- html: custom?.html ?? `
1342
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f9fafb;table-layout:fixed">
1343
- <tr>
1344
- <td align="center" style="padding:40px 16px">
1345
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;background-color:#ffffff;border-radius:12px;border:1px solid #e5e7eb;table-layout:fixed">
1346
- <tr>
1347
- <td style="padding:32px 32px 0">
1348
- <p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#6b7280;font-family:sans-serif;text-transform:uppercase;letter-spacing:0.05em">Dyrected</p>
1349
- <h1 style="margin:0 0 24px;font-size:22px;font-weight:700;color:#111827;font-family:sans-serif">Welcome!</h1>
1350
- </td>
1351
- </tr>
1352
- <tr>
1353
- <td style="padding:0 32px">
1354
- <p style="margin:0 0 12px;font-size:14px;color:#4b5563;line-height:1.6;font-family:sans-serif">Your account has been created. You can now log in with:</p>
1355
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f3f4f6;border-radius:6px;table-layout:fixed">
1356
- <tr>
1357
- <td style="padding:12px 16px;font-size:14px;font-weight:600;color:#111827;font-family:sans-serif;word-break:break-all">
1358
- ${args.email}
1359
- </td>
1360
- </tr>
1361
- </table>
1362
- </td>
1363
- </tr>
1364
- <tr>
1365
- <td style="padding:32px">
1366
- <p style="margin:0;font-size:12px;color:#9ca3af;font-family:sans-serif">If you didn't create this account, you can safely ignore this email.</p>
1367
- </td>
1368
- </tr>
1369
- </table>
1370
- </td>
1371
- </tr>
1372
- </table>`
1739
+ html: custom?.html ?? layout({
1740
+ preheader: "Your Dyrected account is ready.",
1741
+ title: "Welcome \u2014 your account is ready",
1742
+ content: `${paragraph("Your account has been created. You can now log in with:")}${detailBox(args.email)}`,
1743
+ footer: "If you didn't create this account, you can safely ignore this email."
1744
+ })
1373
1745
  };
1374
1746
  }
1375
1747
  function buildInviteEmail(config, args) {
1376
1748
  const custom = config.email?.templates?.invite?.(args);
1377
1749
  return {
1378
1750
  subject: custom?.subject ?? "You've been invited",
1379
- html: custom?.html ?? `
1380
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f9fafb;table-layout:fixed">
1381
- <tr>
1382
- <td align="center" style="padding:40px 16px">
1383
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;background-color:#ffffff;border-radius:12px;border:1px solid #e5e7eb;table-layout:fixed">
1384
- <tr>
1385
- <td style="padding:32px 32px 0">
1386
- <p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#6b7280;font-family:sans-serif;text-transform:uppercase;letter-spacing:0.05em">Dyrected</p>
1387
- <h1 style="margin:0 0 24px;font-size:22px;font-weight:700;color:#111827;font-family:sans-serif">You've been invited</h1>
1388
- </td>
1389
- </tr>
1390
- <tr>
1391
- <td style="padding:0 32px">
1392
- ${args.invitedByEmail ? `<p style="margin:0 0 12px;font-size:14px;color:#4b5563;line-height:1.6;font-family:sans-serif">You were invited by <strong style="color:#111827">${args.invitedByEmail}</strong>.</p>` : ""}
1393
- <p style="margin:0 0 16px;font-size:14px;color:#4b5563;line-height:1.6;font-family:sans-serif">Use the token below to accept your invitation. It expires in 7 days.</p>
1394
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f3f4f6;border-radius:6px;table-layout:fixed">
1395
- <tr>
1396
- <td style="padding:12px 16px;font-family:monospace;font-size:12px;color:#374151;word-break:break-all;white-space:normal;line-height:1.4">
1397
- ${args.token}
1398
- </td>
1399
- </tr>
1400
- </table>
1401
- </td>
1402
- </tr>
1403
- <tr>
1404
- <td style="padding:32px">
1405
- <p style="margin:0;font-size:12px;color:#9ca3af;font-family:sans-serif">If you weren't expecting this invitation, you can safely ignore this email.</p>
1406
- </td>
1407
- </tr>
1408
- </table>
1409
- </td>
1410
- </tr>
1411
- </table>`
1751
+ html: custom?.html ?? layout({
1752
+ preheader: "You've been invited to join a Dyrected account.",
1753
+ title: "You've been invited",
1754
+ content: `${args.invitedByEmail ? paragraph(`You were invited by ${args.invitedByEmail}.`) : ""}${paragraph("Use the token below to accept your invitation. It expires in 7 days.")}${sectionLabel("Invitation token")}${detailBox(args.token, true)}`,
1755
+ footer: "If you weren't expecting this invitation, you can safely ignore this email."
1756
+ })
1412
1757
  };
1413
1758
  }
1414
1759
  function buildResetPasswordEmail(config, args) {
@@ -1416,95 +1761,24 @@ function buildResetPasswordEmail(config, args) {
1416
1761
  const resetLink = args.url;
1417
1762
  return {
1418
1763
  subject: custom?.subject ?? "Reset your password",
1419
- html: custom?.html ?? `
1420
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f9fafb;table-layout:fixed">
1421
- <tr>
1422
- <td align="center" style="padding:40px 16px">
1423
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;background-color:#ffffff;border-radius:12px;border:1px solid #e5e7eb;table-layout:fixed">
1424
- <tr>
1425
- <td style="padding:32px 32px 0">
1426
- <p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#6b7280;font-family:sans-serif;text-transform:uppercase;letter-spacing:0.05em">Dyrected</p>
1427
- <h1 style="margin:0 0 24px;font-size:22px;font-weight:700;color:#111827;font-family:sans-serif">Reset your password</h1>
1428
- </td>
1429
- </tr>
1430
- <tr>
1431
- <td style="padding:0 32px">
1432
- <p style="margin:0 0 24px;font-size:14px;color:#4b5563;line-height:1.6;font-family:sans-serif">We received a request to reset your password. Use the button below to set a new password. It will expire in 1 hour.</p>
1433
- ${resetLink ? `
1434
- <table cellpadding="0" cellspacing="0" border="0" style="margin-bottom:24px">
1435
- <tr>
1436
- <td style="border-radius:6px;background-color:#111827">
1437
- <a href="${resetLink}" style="display:inline-block;padding:12px 28px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;font-family:sans-serif;border-radius:6px">
1438
- Reset Password
1439
- </a>
1440
- </td>
1441
- </tr>
1442
- </table>
1443
- ` : ""}
1444
- <p style="margin:0 0 8px;font-size:12px;color:#9ca3af;font-family:sans-serif">Or copy and paste this token manually in the admin dashboard:</p>
1445
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f3f4f6;border-radius:6px;table-layout:fixed">
1446
- <tr>
1447
- <td style="padding:12px 16px;font-family:monospace;font-size:12px;color:#374151;word-break:break-all;white-space:normal;line-height:1.4">
1448
- ${args.token}
1449
- </td>
1450
- </tr>
1451
- </table>
1452
- </td>
1453
- </tr>
1454
- <tr>
1455
- <td style="padding:32px">
1456
- <p style="margin:0;font-size:12px;color:#9ca3af;font-family:sans-serif">If you didn't request a password reset, you can safely ignore this email.</p>
1457
- </td>
1458
- </tr>
1459
- </table>
1460
- </td>
1461
- </tr>
1462
- </table>`
1764
+ html: custom?.html ?? layout({
1765
+ preheader: "Reset your Dyrected password.",
1766
+ title: "Reset your password",
1767
+ content: `${paragraph("We received a request to reset your password. The reset link and token expire in 1 hour.")}${resetLink ? ctaButton("Reset password", resetLink) : ""}${sectionLabel(resetLink ? "Or use this token" : "Reset token")}${detailBox(args.token, true)}`,
1768
+ footer: "If you didn't request a password reset, you can safely ignore this email."
1769
+ })
1463
1770
  };
1464
1771
  }
1465
1772
  function buildPasswordChangedEmail(config, args) {
1466
1773
  const custom = config.email?.templates?.passwordChanged?.(args);
1467
1774
  return {
1468
1775
  subject: custom?.subject ?? "Your password has been changed",
1469
- html: custom?.html ?? `
1470
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f9fafb;table-layout:fixed">
1471
- <tr>
1472
- <td align="center" style="padding:40px 16px">
1473
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;background-color:#ffffff;border-radius:12px;border:1px solid #e5e7eb;table-layout:fixed">
1474
- <tr>
1475
- <td style="padding:32px 32px 0">
1476
- <p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#6b7280;font-family:sans-serif;text-transform:uppercase;letter-spacing:0.05em">Dyrected</p>
1477
- <h1 style="margin:0 0 24px;font-size:22px;font-weight:700;color:#111827;font-family:sans-serif">Password changed</h1>
1478
- </td>
1479
- </tr>
1480
- <tr>
1481
- <td style="padding:0 32px">
1482
- <p style="margin:0 0 12px;font-size:14px;color:#4b5563;line-height:1.6;font-family:sans-serif">The password for your account was just changed:</p>
1483
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f3f4f6;border-radius:6px;table-layout:fixed">
1484
- <tr>
1485
- <td style="padding:12px 16px;font-size:14px;font-weight:600;color:#111827;font-family:sans-serif;word-break:break-all">
1486
- ${args.email}
1487
- </td>
1488
- </tr>
1489
- </table>
1490
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;margin-top:16px;background-color:#fef2f2;border-radius:6px;border:1px solid #fecaca;table-layout:fixed">
1491
- <tr>
1492
- <td style="padding:12px 16px;font-size:13px;color:#b91c1c;line-height:1.5;font-family:sans-serif">
1493
- If you did not make this change, please contact support immediately.
1494
- </td>
1495
- </tr>
1496
- </table>
1497
- </td>
1498
- </tr>
1499
- <tr>
1500
- <td style="padding:32px">
1501
- <p style="margin:0;font-size:12px;color:#9ca3af;font-family:sans-serif">This is an automated security notification.</p>
1502
- </td>
1503
- </tr>
1504
- </table>
1505
- </td>
1506
- </tr>
1507
- </table>`
1776
+ html: custom?.html ?? layout({
1777
+ preheader: "Your Dyrected password was changed.",
1778
+ title: "Password changed",
1779
+ content: `${paragraph("The password for this account was just changed:")}${detailBox(args.email)}${spacer()}${alertBox("If you did not make this change, please contact support immediately.")}`,
1780
+ footer: "This is an automated security notification."
1781
+ })
1508
1782
  };
1509
1783
  }
1510
1784
 
@@ -1888,7 +2162,76 @@ function generateOpenApi(config) {
1888
2162
  description: "Automatically generated OpenAPI specification for the Dyrected project."
1889
2163
  },
1890
2164
  components: {
1891
- schemas: {},
2165
+ schemas: {
2166
+ WorkflowMetadata: {
2167
+ type: "object",
2168
+ required: ["state", "revision"],
2169
+ properties: {
2170
+ state: { type: "string" },
2171
+ revision: { type: "integer", minimum: 1 },
2172
+ publishedRevision: { type: "integer", minimum: 1 },
2173
+ publishedAt: { type: "string", format: "date-time" },
2174
+ publishedBy: { type: "string" },
2175
+ availableTransitions: { type: "array", items: { type: "string" } }
2176
+ }
2177
+ },
2178
+ WorkflowTransitionRequest: {
2179
+ type: "object",
2180
+ properties: {
2181
+ expectedRevision: { type: "integer", minimum: 1 },
2182
+ comment: { type: "string" }
2183
+ }
2184
+ },
2185
+ WorkflowHistoryEntry: {
2186
+ type: "object",
2187
+ required: [
2188
+ "collection",
2189
+ "documentId",
2190
+ "transition",
2191
+ "from",
2192
+ "to",
2193
+ "revision",
2194
+ "createdAt"
2195
+ ],
2196
+ properties: {
2197
+ id: { type: "string" },
2198
+ collection: { type: "string" },
2199
+ documentId: { type: "string" },
2200
+ transition: { type: "string" },
2201
+ from: { type: "string" },
2202
+ to: { type: "string" },
2203
+ revision: { type: "integer" },
2204
+ comment: { type: "string", nullable: true },
2205
+ actorId: { type: "string", nullable: true },
2206
+ createdAt: { type: "string", format: "date-time" }
2207
+ }
2208
+ },
2209
+ Error: {
2210
+ type: "object",
2211
+ properties: {
2212
+ message: { type: "string" },
2213
+ errors: {
2214
+ type: "array",
2215
+ items: { type: "object", additionalProperties: true }
2216
+ }
2217
+ }
2218
+ },
2219
+ AuthCredentials: {
2220
+ type: "object",
2221
+ required: ["email", "password"],
2222
+ properties: {
2223
+ email: { type: "string", format: "email" },
2224
+ password: { type: "string" }
2225
+ }
2226
+ },
2227
+ TokenResponse: {
2228
+ type: "object",
2229
+ properties: {
2230
+ token: { type: "string" },
2231
+ user: { type: "object", additionalProperties: true }
2232
+ }
2233
+ }
2234
+ },
1892
2235
  securitySchemes: {
1893
2236
  ApiKeyAuth: {
1894
2237
  type: "apiKey",
@@ -1900,6 +2243,98 @@ function generateOpenApi(config) {
1900
2243
  paths: {},
1901
2244
  security: [{ ApiKeyAuth: [] }]
1902
2245
  };
2246
+ spec.paths["/api/schemas"] = {
2247
+ get: {
2248
+ tags: ["System"],
2249
+ summary: "Get the serialized Dyrected schema",
2250
+ security: [],
2251
+ responses: {
2252
+ 200: { description: "Collection and global schema definitions" }
2253
+ }
2254
+ }
2255
+ };
2256
+ spec.paths["/api/openapi.json"] = {
2257
+ get: {
2258
+ tags: ["System"],
2259
+ summary: "Get the OpenAPI specification",
2260
+ security: [],
2261
+ responses: { 200: { description: "OpenAPI 3.0 document" } }
2262
+ }
2263
+ };
2264
+ spec.paths["/api/docs"] = {
2265
+ get: {
2266
+ tags: ["System"],
2267
+ summary: "Open interactive API documentation",
2268
+ security: [],
2269
+ responses: { 200: { description: "Swagger UI HTML" } }
2270
+ }
2271
+ };
2272
+ spec.paths["/api/dyrected/options/{collection}/{field}"] = {
2273
+ get: {
2274
+ tags: ["System"],
2275
+ summary: "Resolve dynamic field options",
2276
+ parameters: [
2277
+ {
2278
+ name: "collection",
2279
+ in: "path",
2280
+ required: true,
2281
+ schema: { type: "string" }
2282
+ },
2283
+ {
2284
+ name: "field",
2285
+ in: "path",
2286
+ required: true,
2287
+ schema: { type: "string" }
2288
+ }
2289
+ ],
2290
+ responses: { 200: { description: "Resolved option items" } }
2291
+ }
2292
+ };
2293
+ spec.paths["/api/preferences/{key}"] = {
2294
+ get: {
2295
+ tags: ["Preferences"],
2296
+ summary: "Get an authenticated user preference",
2297
+ parameters: [
2298
+ { name: "key", in: "path", required: true, schema: { type: "string" } }
2299
+ ],
2300
+ responses: { 200: { description: "Preference value" } }
2301
+ },
2302
+ put: {
2303
+ tags: ["Preferences"],
2304
+ summary: "Set an authenticated user preference",
2305
+ parameters: [
2306
+ { name: "key", in: "path", required: true, schema: { type: "string" } }
2307
+ ],
2308
+ requestBody: {
2309
+ required: true,
2310
+ content: { "application/json": { schema: { type: "object" } } }
2311
+ },
2312
+ responses: { 200: { description: "Updated preference" } }
2313
+ }
2314
+ };
2315
+ spec.paths["/api/preview-token"] = {
2316
+ post: {
2317
+ tags: ["Preview"],
2318
+ summary: "Create a preview token",
2319
+ responses: { 200: { description: "Short-lived preview token" } }
2320
+ }
2321
+ };
2322
+ spec.paths["/api/preview-data"] = {
2323
+ get: {
2324
+ tags: ["Preview"],
2325
+ summary: "Resolve preview data from a token",
2326
+ security: [],
2327
+ parameters: [
2328
+ {
2329
+ name: "token",
2330
+ in: "query",
2331
+ required: true,
2332
+ schema: { type: "string" }
2333
+ }
2334
+ ],
2335
+ responses: { 200: { description: "Preview document data" } }
2336
+ }
2337
+ };
1903
2338
  for (const collection of config.collections) {
1904
2339
  spec.components.schemas[collection.slug] = collectionToSchema(collection);
1905
2340
  }
@@ -1915,10 +2350,28 @@ function generateOpenApi(config) {
1915
2350
  tags: ["Collections"],
1916
2351
  summary: `Find ${labels.plural}`,
1917
2352
  parameters: [
1918
- { name: "limit", in: "query", schema: { type: "integer", default: 10 } },
1919
- { name: "page", in: "query", schema: { type: "integer", default: 1 } },
1920
- { name: "where", in: "query", schema: { type: "string" }, description: "JSON filter" },
1921
- { name: "sort", in: "query", schema: { type: "string" }, description: "Sort field (e.g. -createdAt)" }
2353
+ {
2354
+ name: "limit",
2355
+ in: "query",
2356
+ schema: { type: "integer", default: 10 }
2357
+ },
2358
+ {
2359
+ name: "page",
2360
+ in: "query",
2361
+ schema: { type: "integer", default: 1 }
2362
+ },
2363
+ {
2364
+ name: "where",
2365
+ in: "query",
2366
+ schema: { type: "string" },
2367
+ description: "JSON filter"
2368
+ },
2369
+ {
2370
+ name: "sort",
2371
+ in: "query",
2372
+ schema: { type: "string" },
2373
+ description: "Sort field (e.g. -createdAt)"
2374
+ }
1922
2375
  ],
1923
2376
  responses: {
1924
2377
  200: {
@@ -1928,7 +2381,10 @@ function generateOpenApi(config) {
1928
2381
  schema: {
1929
2382
  type: "object",
1930
2383
  properties: {
1931
- docs: { type: "array", items: { $ref: `#/components/schemas/${slug}` } },
2384
+ docs: {
2385
+ type: "array",
2386
+ items: { $ref: `#/components/schemas/${slug}` }
2387
+ },
1932
2388
  total: { type: "integer" },
1933
2389
  limit: { type: "integer" },
1934
2390
  page: { type: "integer" }
@@ -1966,7 +2422,14 @@ function generateOpenApi(config) {
1966
2422
  get: {
1967
2423
  tags: ["Collections"],
1968
2424
  summary: `Get a single ${labels.singular}`,
1969
- parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
2425
+ parameters: [
2426
+ {
2427
+ name: "id",
2428
+ in: "path",
2429
+ required: true,
2430
+ schema: { type: "string" }
2431
+ }
2432
+ ],
1970
2433
  responses: {
1971
2434
  200: {
1972
2435
  description: "Success",
@@ -1981,7 +2444,14 @@ function generateOpenApi(config) {
1981
2444
  patch: {
1982
2445
  tags: ["Collections"],
1983
2446
  summary: `Update ${labels.singular}`,
1984
- parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
2447
+ parameters: [
2448
+ {
2449
+ name: "id",
2450
+ in: "path",
2451
+ required: true,
2452
+ schema: { type: "string" }
2453
+ }
2454
+ ],
1985
2455
  requestBody: {
1986
2456
  required: true,
1987
2457
  content: {
@@ -2004,12 +2474,288 @@ function generateOpenApi(config) {
2004
2474
  delete: {
2005
2475
  tags: ["Collections"],
2006
2476
  summary: `Delete ${labels.singular}`,
2007
- parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
2477
+ parameters: [
2478
+ {
2479
+ name: "id",
2480
+ in: "path",
2481
+ required: true,
2482
+ schema: { type: "string" }
2483
+ }
2484
+ ],
2008
2485
  responses: {
2009
2486
  204: { description: "Deleted" }
2010
2487
  }
2011
2488
  }
2012
2489
  };
2490
+ spec.paths[`${path}/delete-many`] = {
2491
+ delete: {
2492
+ tags: ["Collections"],
2493
+ summary: `Delete multiple ${labels.plural}`,
2494
+ requestBody: {
2495
+ required: true,
2496
+ content: {
2497
+ "application/json": {
2498
+ schema: {
2499
+ type: "object",
2500
+ required: ["ids"],
2501
+ properties: {
2502
+ ids: { type: "array", items: { type: "string" } }
2503
+ }
2504
+ }
2505
+ }
2506
+ }
2507
+ },
2508
+ responses: { 200: { description: "Deleted and failed document IDs" } }
2509
+ }
2510
+ };
2511
+ spec.paths[`${path}/seed`] = {
2512
+ post: {
2513
+ tags: ["Collections"],
2514
+ summary: `Seed initial ${labels.plural}`,
2515
+ responses: { 200: { description: "Seeded documents" } }
2516
+ }
2517
+ };
2518
+ if (collection.upload) {
2519
+ spec.paths[`${path}/media`] = {
2520
+ get: {
2521
+ tags: ["Media"],
2522
+ summary: `List ${labels.plural}`,
2523
+ responses: { 200: { description: "Paginated media documents" } }
2524
+ },
2525
+ post: {
2526
+ tags: ["Media"],
2527
+ summary: `Upload ${labels.singular}`,
2528
+ requestBody: {
2529
+ required: true,
2530
+ content: {
2531
+ "multipart/form-data": {
2532
+ schema: {
2533
+ type: "object",
2534
+ required: ["file"],
2535
+ properties: { file: { type: "string", format: "binary" } }
2536
+ }
2537
+ }
2538
+ }
2539
+ },
2540
+ responses: { 201: { description: "Uploaded media document" } }
2541
+ }
2542
+ };
2543
+ spec.paths[`${path}/media/{filename}`] = {
2544
+ get: {
2545
+ tags: ["Media"],
2546
+ summary: `Serve ${labels.singular} bytes`,
2547
+ parameters: [
2548
+ {
2549
+ name: "filename",
2550
+ in: "path",
2551
+ required: true,
2552
+ schema: { type: "string" }
2553
+ }
2554
+ ],
2555
+ security: [],
2556
+ responses: {
2557
+ 200: { description: "Stored file" },
2558
+ 404: { description: "File not found" }
2559
+ }
2560
+ }
2561
+ };
2562
+ }
2563
+ if (collection.auth) {
2564
+ const publicAuthPost = (summary) => ({
2565
+ tags: ["Authentication"],
2566
+ summary,
2567
+ security: [],
2568
+ requestBody: {
2569
+ required: true,
2570
+ content: {
2571
+ "application/json": {
2572
+ schema: { type: "object", additionalProperties: true }
2573
+ }
2574
+ }
2575
+ },
2576
+ responses: {
2577
+ 200: { description: "Success" },
2578
+ 400: { description: "Invalid request" }
2579
+ }
2580
+ });
2581
+ spec.paths[`${path}/login`] = {
2582
+ post: {
2583
+ ...publicAuthPost(`Log in to ${labels.plural}`),
2584
+ requestBody: {
2585
+ required: true,
2586
+ content: {
2587
+ "application/json": {
2588
+ schema: { $ref: "#/components/schemas/AuthCredentials" }
2589
+ }
2590
+ }
2591
+ },
2592
+ responses: {
2593
+ 200: {
2594
+ description: "Authenticated",
2595
+ content: {
2596
+ "application/json": {
2597
+ schema: { $ref: "#/components/schemas/TokenResponse" }
2598
+ }
2599
+ }
2600
+ },
2601
+ 401: { description: "Invalid credentials" }
2602
+ }
2603
+ }
2604
+ };
2605
+ spec.paths[`${path}/logout`] = {
2606
+ post: {
2607
+ tags: ["Authentication"],
2608
+ summary: `Log out of ${labels.plural}`,
2609
+ responses: { 200: { description: "Logged out" } }
2610
+ }
2611
+ };
2612
+ spec.paths[`${path}/init`] = {
2613
+ get: {
2614
+ tags: ["Authentication"],
2615
+ summary: `Get ${labels.plural} initialization state`,
2616
+ security: [],
2617
+ responses: { 200: { description: "Initialization state" } }
2618
+ }
2619
+ };
2620
+ spec.paths[`${path}/first-user`] = {
2621
+ post: publicAuthPost(`Register the first ${labels.singular}`)
2622
+ };
2623
+ spec.paths[`${path}/me`] = {
2624
+ get: {
2625
+ tags: ["Authentication"],
2626
+ summary: `Get the current ${labels.singular}`,
2627
+ responses: { 200: { description: "Authenticated user" } }
2628
+ }
2629
+ };
2630
+ spec.paths[`${path}/refresh-token`] = {
2631
+ post: {
2632
+ tags: ["Authentication"],
2633
+ summary: "Refresh an authentication token",
2634
+ responses: { 200: { description: "Refreshed token" } }
2635
+ }
2636
+ };
2637
+ spec.paths[`${path}/forgot-password`] = {
2638
+ post: publicAuthPost("Request a password reset")
2639
+ };
2640
+ spec.paths[`${path}/reset-password`] = {
2641
+ post: publicAuthPost("Reset a password")
2642
+ };
2643
+ spec.paths[`${path}/invite`] = {
2644
+ post: {
2645
+ tags: ["Authentication"],
2646
+ summary: `Invite a ${labels.singular}`,
2647
+ responses: { 200: { description: "Invitation sent" } }
2648
+ }
2649
+ };
2650
+ spec.paths[`${path}/accept-invite`] = {
2651
+ post: publicAuthPost("Accept an invitation")
2652
+ };
2653
+ spec.paths[`${path}/{id}/change-password`] = {
2654
+ post: {
2655
+ tags: ["Authentication"],
2656
+ summary: `Change a ${labels.singular} password`,
2657
+ parameters: [
2658
+ {
2659
+ name: "id",
2660
+ in: "path",
2661
+ required: true,
2662
+ schema: { type: "string" }
2663
+ }
2664
+ ],
2665
+ responses: { 200: { description: "Password changed" } }
2666
+ }
2667
+ };
2668
+ }
2669
+ if (collection.workflow) {
2670
+ spec.paths[`${path}/{id}/transitions/{transition}`] = {
2671
+ post: {
2672
+ tags: ["Workflows"],
2673
+ summary: `Transition ${labels.singular} workflow`,
2674
+ parameters: [
2675
+ {
2676
+ name: "id",
2677
+ in: "path",
2678
+ required: true,
2679
+ schema: { type: "string" }
2680
+ },
2681
+ {
2682
+ name: "transition",
2683
+ in: "path",
2684
+ required: true,
2685
+ schema: {
2686
+ type: "string",
2687
+ enum: collection.workflow.transitions.map((item) => item.name)
2688
+ }
2689
+ }
2690
+ ],
2691
+ requestBody: {
2692
+ content: {
2693
+ "application/json": {
2694
+ schema: {
2695
+ $ref: "#/components/schemas/WorkflowTransitionRequest"
2696
+ }
2697
+ }
2698
+ }
2699
+ },
2700
+ responses: {
2701
+ 200: {
2702
+ description: "Transition applied",
2703
+ content: {
2704
+ "application/json": {
2705
+ schema: { $ref: `#/components/schemas/${slug}` }
2706
+ }
2707
+ }
2708
+ },
2709
+ 400: { description: "Required transition input is missing" },
2710
+ 403: { description: "Transition capability denied" },
2711
+ 409: { description: "Invalid state or stale revision" }
2712
+ }
2713
+ }
2714
+ };
2715
+ spec.paths[`${path}/{id}/workflow-history`] = {
2716
+ get: {
2717
+ tags: ["Workflows"],
2718
+ summary: `Get ${labels.singular} workflow history`,
2719
+ parameters: [
2720
+ {
2721
+ name: "id",
2722
+ in: "path",
2723
+ required: true,
2724
+ schema: { type: "string" }
2725
+ },
2726
+ {
2727
+ name: "limit",
2728
+ in: "query",
2729
+ schema: { type: "integer", default: 50, maximum: 100 }
2730
+ }
2731
+ ],
2732
+ responses: {
2733
+ 200: {
2734
+ description: "Workflow transition history",
2735
+ content: {
2736
+ "application/json": {
2737
+ schema: {
2738
+ type: "object",
2739
+ properties: {
2740
+ docs: {
2741
+ type: "array",
2742
+ items: {
2743
+ $ref: "#/components/schemas/WorkflowHistoryEntry"
2744
+ }
2745
+ },
2746
+ total: { type: "integer" },
2747
+ limit: { type: "integer" },
2748
+ page: { type: "integer" }
2749
+ }
2750
+ }
2751
+ }
2752
+ }
2753
+ },
2754
+ 403: { description: "Collection read access denied" }
2755
+ }
2756
+ }
2757
+ };
2758
+ }
2013
2759
  }
2014
2760
  for (const global of config.globals) {
2015
2761
  const slug = global.slug;
@@ -2052,45 +2798,31 @@ function generateOpenApi(config) {
2052
2798
  }
2053
2799
  }
2054
2800
  };
2801
+ spec.paths[`${path}/seed`] = {
2802
+ post: {
2803
+ tags: ["Globals"],
2804
+ summary: `Seed ${global.label || slug}`,
2805
+ responses: { 200: { description: "Seeded global" } }
2806
+ }
2807
+ };
2055
2808
  }
2056
2809
  if (config.storage) {
2057
- spec.paths["/api/media"] = {
2810
+ spec.paths["/api/media/{filename}"] = {
2058
2811
  get: {
2059
2812
  tags: ["Media"],
2060
- summary: "List Media",
2061
- responses: {
2062
- 200: {
2063
- description: "Success",
2064
- content: {
2065
- "application/json": {
2066
- schema: {
2067
- type: "object",
2068
- properties: {
2069
- docs: { type: "array", items: { type: "object", additionalProperties: true } }
2070
- }
2071
- }
2072
- }
2073
- }
2074
- }
2075
- }
2076
- },
2077
- post: {
2078
- tags: ["Media"],
2079
- summary: "Upload Media",
2080
- requestBody: {
2081
- content: {
2082
- "multipart/form-data": {
2083
- schema: {
2084
- type: "object",
2085
- properties: {
2086
- file: { type: "string", format: "binary" }
2087
- }
2088
- }
2089
- }
2813
+ summary: "Serve a stored file",
2814
+ security: [],
2815
+ parameters: [
2816
+ {
2817
+ name: "filename",
2818
+ in: "path",
2819
+ required: true,
2820
+ schema: { type: "string" }
2090
2821
  }
2091
- },
2822
+ ],
2092
2823
  responses: {
2093
- 201: { description: "Uploaded" }
2824
+ 200: { description: "Stored file bytes" },
2825
+ 404: { description: "File not found" }
2094
2826
  }
2095
2827
  }
2096
2828
  };
@@ -2105,6 +2837,7 @@ function collectionToSchema(collection) {
2105
2837
  id: { type: "string" },
2106
2838
  createdAt: { type: "string", format: "date-time" },
2107
2839
  updatedAt: { type: "string", format: "date-time" },
2840
+ ...collection.workflow ? { _workflow: { $ref: "#/components/schemas/WorkflowMetadata" } } : {},
2108
2841
  ...properties
2109
2842
  },
2110
2843
  required: ["id", ...required]
@@ -2122,7 +2855,13 @@ function fieldsToProperties(fields) {
2122
2855
  const props = {};
2123
2856
  const required = [];
2124
2857
  for (const field of fields) {
2125
- if (!field.name || field.type === "join" || field.type === "row") continue;
2858
+ if (field.type === "row") {
2859
+ const nested = fieldsToProperties(field.fields || []);
2860
+ Object.assign(props, nested.properties);
2861
+ required.push(...nested.required);
2862
+ continue;
2863
+ }
2864
+ if (!field.name) continue;
2126
2865
  props[field.name] = fieldToSchema(field);
2127
2866
  if (field.required) {
2128
2867
  required.push(field.name);
@@ -2136,7 +2875,17 @@ function fieldToSchema(field) {
2136
2875
  case "text":
2137
2876
  case "textarea":
2138
2877
  case "email":
2878
+ schema = { type: "string" };
2879
+ break;
2139
2880
  case "url":
2881
+ schema = {
2882
+ oneOf: [
2883
+ { type: "string" },
2884
+ { type: "object", additionalProperties: true }
2885
+ ]
2886
+ };
2887
+ break;
2888
+ case "icon":
2140
2889
  schema = { type: "string" };
2141
2890
  break;
2142
2891
  case "number":
@@ -2146,20 +2895,45 @@ function fieldToSchema(field) {
2146
2895
  schema = { type: "boolean" };
2147
2896
  break;
2148
2897
  case "date":
2898
+ schema = { type: "string", format: "date" };
2899
+ break;
2900
+ case "datetime":
2149
2901
  schema = { type: "string", format: "date-time" };
2150
2902
  break;
2903
+ case "time":
2904
+ schema = { type: "string", format: "time" };
2905
+ break;
2151
2906
  case "select":
2152
2907
  case "radio":
2153
- schema = { type: "string", enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0 };
2908
+ schema = {
2909
+ type: "string",
2910
+ enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0
2911
+ };
2154
2912
  break;
2155
2913
  case "multiSelect":
2156
2914
  schema = {
2157
2915
  type: "array",
2158
- items: { type: "string", enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0 }
2916
+ items: {
2917
+ type: "string",
2918
+ enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0
2919
+ }
2159
2920
  };
2160
2921
  break;
2161
2922
  case "relationship":
2162
- schema = { type: "string", description: `ID of a ${field.relationTo} record` };
2923
+ case "image": {
2924
+ const valueSchema = {
2925
+ type: "string",
2926
+ description: `ID of a ${field.relationTo} record`
2927
+ };
2928
+ schema = field.hasMany ? { type: "array", items: valueSchema } : valueSchema;
2929
+ break;
2930
+ }
2931
+ case "join":
2932
+ schema = {
2933
+ type: "array",
2934
+ readOnly: true,
2935
+ items: { type: "object", additionalProperties: true }
2936
+ };
2163
2937
  break;
2164
2938
  case "object": {
2165
2939
  const { properties, required } = fieldsToProperties(field.fields || []);
@@ -2168,7 +2942,10 @@ function fieldToSchema(field) {
2168
2942
  }
2169
2943
  case "array": {
2170
2944
  const { properties, required } = fieldsToProperties(field.fields || []);
2171
- schema = { type: "array", items: { type: "object", properties, required } };
2945
+ schema = {
2946
+ type: "array",
2947
+ items: { type: "object", properties, required }
2948
+ };
2172
2949
  break;
2173
2950
  }
2174
2951
  case "json":
@@ -2249,20 +3026,6 @@ function getSwaggerHtml(specUrl = "/api/openapi.json") {
2249
3026
  `;
2250
3027
  }
2251
3028
 
2252
- // src/auth/jexl.ts
2253
- var import_jexl = __toESM(require("jexl"), 1);
2254
- async function evaluateAccess(expression, context) {
2255
- if (expression === void 0 || expression === null) return false;
2256
- if (typeof expression === "boolean") return expression;
2257
- try {
2258
- const result = await import_jexl.default.eval(expression, context);
2259
- return !!result;
2260
- } catch (err) {
2261
- console.error("[dyrected/core] Jexl evaluation failed:", err);
2262
- return false;
2263
- }
2264
- }
2265
-
2266
3029
  // src/router.ts
2267
3030
  function accessGate(target, action) {
2268
3031
  return async (c, next) => {
@@ -2373,7 +3136,22 @@ function registerRoutes(app, config) {
2373
3136
  }))),
2374
3137
  upload: !!col.upload,
2375
3138
  auth: !!col.auth,
2376
- admin: col.admin
3139
+ admin: col.admin,
3140
+ workflow: col.workflow ? {
3141
+ initialState: col.workflow.initialState,
3142
+ draftState: col.workflow.draftState,
3143
+ states: col.workflow.states,
3144
+ transitions: col.workflow.transitions.map((t) => ({
3145
+ name: t.name,
3146
+ label: t.label,
3147
+ from: t.from,
3148
+ to: t.to,
3149
+ requiredCapabilities: t.requiredCapabilities,
3150
+ requireComment: t.requireComment,
3151
+ unpublish: t.unpublish
3152
+ })),
3153
+ roles: col.workflow.roles
3154
+ } : void 0
2377
3155
  })));
2378
3156
  const filteredGlobals = await Promise.all(globals.filter((glb) => !siteId || glb.shared || !glb.siteId || glb.siteId === siteId).map(async (glb) => ({
2379
3157
  slug: glb.slug,
@@ -2567,6 +3345,10 @@ function registerRoutes(app, config) {
2567
3345
  if (collection.auth) {
2568
3346
  app.post(`${path}/:id/change-password`, requireAuth(), (c) => controller.changePassword(c));
2569
3347
  }
3348
+ if (collection.workflow) {
3349
+ app.post(`${path}/:id/transitions/:transition`, requireAuth(), (c) => controller.transition(c));
3350
+ app.get(`${path}/:id/workflow-history`, requireAuth(), (c) => controller.workflowHistory(c));
3351
+ }
2570
3352
  }
2571
3353
  for (const global of config.globals) {
2572
3354
  const path = `/api/globals/${global.slug}`;
@@ -2578,6 +3360,41 @@ function registerRoutes(app, config) {
2578
3360
  const previewController = new PreviewController();
2579
3361
  app.post("/api/preview-token", requireAuth(), (c) => previewController.createToken(c));
2580
3362
  app.get("/api/preview-data", (c) => previewController.getData(c));
3363
+ app.all("/api/collections/:slug/:id/*", requireAuth(), async (c) => {
3364
+ const slug = c.req.param("slug");
3365
+ const id = c.req.param("id");
3366
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
3367
+ const config2 = c.get("config");
3368
+ if (config2.collections.some((col) => col.slug === slug)) {
3369
+ return c.json({ message: "Not Found" }, 404);
3370
+ }
3371
+ if (!config2.onSchemaFetch || !siteId) {
3372
+ return c.json({ message: `Collection "${slug}" not found` }, 404);
3373
+ }
3374
+ const dynamic = await config2.onSchemaFetch(siteId);
3375
+ const collection = dynamic.collections?.find((col) => col.slug === slug);
3376
+ if (!collection?.workflow) {
3377
+ return c.json({ message: `Collection "${slug}" not found or has no workflow` }, 404);
3378
+ }
3379
+ const controller = new CollectionController(collection);
3380
+ const rawPath = c.req.path;
3381
+ const method = c.req.method;
3382
+ const transitionMatch = rawPath.match(/\/transitions\/([^/]+)$/);
3383
+ if (transitionMatch && method === "POST") {
3384
+ c.req.raw.__transition = transitionMatch[1];
3385
+ const origParam = c.req.param.bind(c.req);
3386
+ c.req.param = (key) => {
3387
+ if (key === "transition") return transitionMatch[1];
3388
+ if (key === "id") return id;
3389
+ return origParam(key);
3390
+ };
3391
+ return controller.transition(c);
3392
+ }
3393
+ if (rawPath.endsWith("/workflow-history") && method === "GET") {
3394
+ return controller.workflowHistory(c);
3395
+ }
3396
+ return c.json({ message: "Not Found" }, 404);
3397
+ });
2581
3398
  app.all("/api/collections/:slug/:id?", async (c) => {
2582
3399
  const slug = c.req.param("slug");
2583
3400
  const id = c.req.param("id");
@@ -2686,10 +3503,47 @@ var AUDIT_COLLECTION = {
2686
3503
  ],
2687
3504
  admin: { hidden: true }
2688
3505
  };
3506
+ var WORKFLOW_HISTORY_COLLECTION_CONFIG = {
3507
+ slug: WORKFLOW_HISTORY_COLLECTION,
3508
+ labels: { singular: "Workflow transition", plural: "Workflow transitions" },
3509
+ fields: [
3510
+ { name: "collection", type: "text", required: true },
3511
+ { name: "documentId", type: "text", required: true },
3512
+ { name: "transition", type: "text", required: true },
3513
+ { name: "from", type: "text", required: true },
3514
+ { name: "to", type: "text", required: true },
3515
+ { name: "revision", type: "number", required: true },
3516
+ { name: "comment", type: "textarea" },
3517
+ { name: "actorId", type: "text" },
3518
+ { name: "createdAt", type: "date", required: true }
3519
+ ],
3520
+ access: { read: ({ user }) => !!user, create: () => false, update: () => false, delete: () => false },
3521
+ admin: { hidden: true }
3522
+ };
3523
+ var LIFECYCLE_EVENTS_COLLECTION_CONFIG = {
3524
+ slug: LIFECYCLE_EVENTS_COLLECTION,
3525
+ labels: { singular: "Lifecycle event", plural: "Lifecycle events" },
3526
+ fields: [
3527
+ { name: "name", type: "text", required: true },
3528
+ { name: "collection", type: "text", required: true },
3529
+ { name: "documentId", type: "text", required: true },
3530
+ { name: "occurredAt", type: "date", required: true },
3531
+ { name: "actorId", type: "text" },
3532
+ { name: "payload", type: "json", required: true },
3533
+ { name: "attempts", type: "number", required: true },
3534
+ { name: "status", type: "select", options: ["pending", "processing", "delivered", "failed"], required: true },
3535
+ { name: "nextAttemptAt", type: "date" },
3536
+ { name: "deliveredAt", type: "date" },
3537
+ { name: "lastError", type: "textarea" }
3538
+ ],
3539
+ access: { read: ({ user }) => !!user?.roles?.includes("admin"), create: () => false, update: () => false, delete: () => false },
3540
+ admin: { hidden: true }
3541
+ };
2689
3542
  function normalizeConfig(config) {
2690
3543
  const collections = config?.collections || [];
2691
3544
  const globals = config?.globals || [];
2692
3545
  const needsAudit = collections.some((col) => col.audit);
3546
+ const needsWorkflow = collections.some((col) => col.workflow);
2693
3547
  const normalizedCollections = collections.map((col) => {
2694
3548
  let fields = col.fields || [];
2695
3549
  const existingFieldNames = new Set(fields.map((f) => f.name));
@@ -2784,9 +3638,17 @@ function normalizeConfig(config) {
2784
3638
  };
2785
3639
  });
2786
3640
  const hasAuditCollection = normalizedCollections.some((col) => col.slug === AUDIT_COLLECTION_SLUG);
3641
+ const systemCollections = [];
3642
+ if (needsAudit && !hasAuditCollection) systemCollections.push(AUDIT_COLLECTION);
3643
+ if (needsWorkflow && !normalizedCollections.some((col) => col.slug === WORKFLOW_HISTORY_COLLECTION)) {
3644
+ systemCollections.push(WORKFLOW_HISTORY_COLLECTION_CONFIG);
3645
+ }
3646
+ if (needsWorkflow && !normalizedCollections.some((col) => col.slug === LIFECYCLE_EVENTS_COLLECTION)) {
3647
+ systemCollections.push(LIFECYCLE_EVENTS_COLLECTION_CONFIG);
3648
+ }
2787
3649
  return {
2788
3650
  ...config,
2789
- collections: [...normalizedCollections, ...needsAudit && !hasAuditCollection ? [AUDIT_COLLECTION] : []],
3651
+ collections: [...normalizedCollections, ...systemCollections],
2790
3652
  globals
2791
3653
  };
2792
3654
  }