@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/chunk-6ZXEGKT6.js +619 -0
- package/dist/chunk-CQDVPMEU.js +613 -0
- package/dist/chunk-Y4AYI52W.js +631 -0
- package/dist/chunk-YDOBB7MY.js +1457 -0
- package/dist/index-CdQwnbfh.d.cts +1889 -0
- package/dist/index-CdQwnbfh.d.ts +1889 -0
- package/dist/index-DTrpTru7.d.cts +1881 -0
- package/dist/index-DTrpTru7.d.ts +1881 -0
- package/dist/index-DzUlE5Hc.d.cts +1751 -0
- package/dist/index-DzUlE5Hc.d.ts +1751 -0
- package/dist/index-M_m6dXZV.d.cts +1889 -0
- package/dist/index-M_m6dXZV.d.ts +1889 -0
- package/dist/index-qhLus8ZO.d.cts +1881 -0
- package/dist/index-qhLus8ZO.d.ts +1881 -0
- package/dist/index.cjs +1159 -812
- package/dist/index.d.cts +66 -18
- package/dist/index.d.ts +66 -18
- package/dist/index.js +42 -825
- package/dist/server.cjs +1079 -217
- package/dist/server.d.cts +33 -1
- package/dist/server.d.ts +33 -1
- package/dist/server.js +267 -494
- package/dist/where-sanitizer-WJ2W2QCE.js +54 -0
- package/package.json +2 -1
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
|
-
|
|
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
|
+
"&": "&",
|
|
1629
|
+
"<": "<",
|
|
1630
|
+
">": ">",
|
|
1631
|
+
"'": "'",
|
|
1632
|
+
'"': """
|
|
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"> </td></tr></table>`;
|
|
1654
|
+
}
|
|
1655
|
+
function spacer(height = 16) {
|
|
1656
|
+
return table(row(" ", `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(" ", `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
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
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
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
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
|
-
{
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
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: {
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: "
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
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
|
-
|
|
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 (
|
|
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 = {
|
|
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: {
|
|
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
|
-
|
|
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 = {
|
|
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, ...
|
|
3651
|
+
collections: [...normalizedCollections, ...systemCollections],
|
|
2790
3652
|
globals
|
|
2791
3653
|
};
|
|
2792
3654
|
}
|