@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.js CHANGED
@@ -1,9 +1,17 @@
1
1
  import {
2
+ WORKFLOW_HISTORY_COLLECTION,
3
+ canViewWorkflowDraft,
4
+ createWorkflowDocument,
2
5
  executeFieldAfterRead,
3
6
  executeFieldBeforeChange,
7
+ generateOpenApi,
8
+ initializeWorkflowDocument,
9
+ materializeWorkflowDocument,
4
10
  normalizeConfig,
5
- runCollectionHooks
6
- } from "./chunk-SUGK7UYL.js";
11
+ runCollectionHooks,
12
+ saveWorkflowDraft,
13
+ transitionWorkflow
14
+ } from "./chunk-YDOBB7MY.js";
7
15
 
8
16
  // src/app.ts
9
17
  import { Hono } from "hono";
@@ -276,6 +284,20 @@ function createReadonlyDb(db) {
276
284
  });
277
285
  }
278
286
 
287
+ // src/auth/jexl.ts
288
+ import jexl from "jexl";
289
+ async function evaluateAccess(expression, context) {
290
+ if (expression === void 0 || expression === null) return false;
291
+ if (typeof expression === "boolean") return expression;
292
+ try {
293
+ const result = await jexl.eval(expression, context);
294
+ return !!result;
295
+ } catch (err) {
296
+ console.error("[dyrected/core] Jexl evaluation failed:", err);
297
+ return false;
298
+ }
299
+ }
300
+
279
301
  // src/controllers/collection.controller.ts
280
302
  var CollectionController = class {
281
303
  collection;
@@ -304,7 +326,7 @@ var CollectionController = class {
304
326
  if (this.collection.admin?.filterable === false) {
305
327
  where = void 0;
306
328
  } else {
307
- const { sanitizeWhereClause } = await import("./where-sanitizer-DQIWTQZW.js");
329
+ const { sanitizeWhereClause } = await import("./where-sanitizer-WJ2W2QCE.js");
308
330
  where = sanitizeWhereClause(where, this.collection.fields);
309
331
  if (Object.keys(where).length === 0) {
310
332
  where = void 0;
@@ -320,6 +342,9 @@ var CollectionController = class {
320
342
  if (beforeReadResult !== void 0) {
321
343
  where = beforeReadResult;
322
344
  }
345
+ if (this.collection.workflow && !canViewWorkflowDraft(this.collection.workflow, user)) {
346
+ where = where ? { AND: [where, { __published: { exists: true } }] } : { __published: { exists: true } };
347
+ }
323
348
  let result = await db.find({
324
349
  collection: this.collection.slug,
325
350
  limit,
@@ -340,7 +365,7 @@ var CollectionController = class {
340
365
  where
341
366
  });
342
367
  }
343
- result.docs = result.docs.map((doc) => DefaultsService.apply(this.collection.fields, doc));
368
+ 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));
344
369
  const processedDocs = [];
345
370
  for (const doc of result.docs) {
346
371
  const docWithCollectionHooks = await runCollectionHooks(this.collection.hooks?.afterRead, {
@@ -368,8 +393,12 @@ var CollectionController = class {
368
393
  const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 10;
369
394
  const user = c.get("user");
370
395
  if (!id) return c.json({ message: "Missing ID" }, 400);
371
- const doc = await db.findOne({ collection: this.collection.slug, id });
396
+ let doc = await db.findOne({ collection: this.collection.slug, id });
372
397
  if (!doc) return c.json({ message: "Not Found" }, 404);
398
+ if (this.collection.workflow) {
399
+ doc = materializeWorkflowDocument(doc, this.collection.workflow, user);
400
+ if (!doc) return c.json({ message: "Not Found" }, 404);
401
+ }
373
402
  const docWithDefaults = DefaultsService.apply(this.collection.fields, doc);
374
403
  const docWithCollectionHooks = await runCollectionHooks(this.collection.hooks?.afterRead, {
375
404
  doc: docWithDefaults,
@@ -409,6 +438,9 @@ var CollectionController = class {
409
438
  createdBy: user?.sub ?? null,
410
439
  updatedBy: user?.sub ?? null
411
440
  };
441
+ if (this.collection.workflow) {
442
+ data = initializeWorkflowDocument(data, this.collection.workflow);
443
+ }
412
444
  if (this.collection.auth && data.password) {
413
445
  data.password = await hashPassword(data.password);
414
446
  }
@@ -420,7 +452,7 @@ var CollectionController = class {
420
452
  operation: "create",
421
453
  db: readonlyDb
422
454
  });
423
- const doc = await db.create({ collection: this.collection.slug, data });
455
+ const doc = this.collection.workflow ? (await createWorkflowDocument({ config, collection: this.collection, data, user })).doc : await db.create({ collection: this.collection.slug, data });
424
456
  if (this.collection.audit && db) {
425
457
  AuditService.log(db, {
426
458
  operation: "create",
@@ -438,8 +470,9 @@ var CollectionController = class {
438
470
  operation: "create",
439
471
  db
440
472
  }, { isolated: true });
473
+ const responseDoc = this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc;
441
474
  const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
442
- doc,
475
+ doc: responseDoc,
443
476
  req: c.req,
444
477
  user,
445
478
  db: readonlyDb
@@ -502,8 +535,9 @@ var CollectionController = class {
502
535
  operation: "create",
503
536
  db
504
537
  }, { isolated: true });
538
+ const responseDoc = this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc;
505
539
  const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
506
- doc,
540
+ doc: responseDoc,
507
541
  req: c.req,
508
542
  user,
509
543
  db: readonlyDb
@@ -545,7 +579,7 @@ var CollectionController = class {
545
579
  operation: "update",
546
580
  db: readonlyDb
547
581
  });
548
- const doc = await db.update({ collection: this.collection.slug, id, data });
582
+ 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 });
549
583
  if (this.collection.audit && db) {
550
584
  AuditService.log(db, {
551
585
  operation: "update",
@@ -573,6 +607,61 @@ var CollectionController = class {
573
607
  const finalDoc = await executeFieldAfterRead(this.collection.fields, readDoc, user, readonlyDb);
574
608
  return c.json(finalDoc);
575
609
  }
610
+ async transition(c) {
611
+ const config = c.get("config");
612
+ if (!config.db) return c.json({ message: "Database not configured" }, 500);
613
+ if (!this.collection.workflow) return c.json({ message: "Workflows are not enabled for this collection" }, 404);
614
+ const id = c.req.param("id");
615
+ const transitionName = c.req.param("transition");
616
+ const body = await c.req.json().catch(() => ({}));
617
+ try {
618
+ const doc = await transitionWorkflow({
619
+ config,
620
+ collection: this.collection,
621
+ id,
622
+ transitionName,
623
+ expectedRevision: body.expectedRevision,
624
+ comment: body.comment,
625
+ user: c.get("user"),
626
+ req: { query: c.req.query(), headers: c.req.header(), raw: c.req.raw }
627
+ });
628
+ return c.json(materializeWorkflowDocument(doc, this.collection.workflow, c.get("user")));
629
+ } catch (error) {
630
+ const status = typeof error.statusCode === "number" ? error.statusCode : 500;
631
+ return c.json({ error: true, message: error instanceof Error ? error.message : String(error) }, status);
632
+ }
633
+ }
634
+ async workflowHistory(c) {
635
+ const config = c.get("config");
636
+ if (!config.db) return c.json({ message: "Database not configured" }, 500);
637
+ if (!this.collection.workflow) return c.json({ message: "Workflows are not enabled for this collection" }, 404);
638
+ const documentId = c.req.param("id");
639
+ if (!documentId) return c.json({ message: "Missing ID" }, 400);
640
+ const document = await config.db.findOne({ collection: this.collection.slug, id: documentId });
641
+ if (!document) return c.json({ message: "Not Found" }, 404);
642
+ const readAccess = this.collection.access?.read;
643
+ if (readAccess !== void 0 && readAccess !== null) {
644
+ const args = { user: c.get("user"), req: c.req, doc: document };
645
+ const result2 = typeof readAccess === "function" ? await readAccess(args) : await evaluateAccess(readAccess, args);
646
+ let allowed = result2 === true;
647
+ if (result2 && typeof result2 === "object") {
648
+ const match = await config.db.find({
649
+ collection: this.collection.slug,
650
+ where: { AND: [{ id: { equals: documentId } }, result2] },
651
+ limit: 1
652
+ });
653
+ allowed = match.total > 0;
654
+ }
655
+ if (!allowed) return c.json({ error: true, message: `Access denied: read on ${this.collection.slug}` }, 403);
656
+ }
657
+ const result = await config.db.find({
658
+ collection: WORKFLOW_HISTORY_COLLECTION,
659
+ where: { collection: { equals: this.collection.slug }, documentId: { equals: documentId } },
660
+ sort: "-createdAt",
661
+ limit: Math.min(Number(c.req.query("limit")) || 50, 100)
662
+ });
663
+ return c.json(result);
664
+ }
576
665
  /**
577
666
  * POST /api/collections/:slug/:id/change-password
578
667
  *
@@ -1040,6 +1129,96 @@ async function verifyCollectionToken(token) {
1040
1129
  return payload;
1041
1130
  }
1042
1131
 
1132
+ // src/services/email-template.ts
1133
+ var emailTokens = {
1134
+ colors: {
1135
+ canvas: "#f6f7f2",
1136
+ surface: "#ffffff",
1137
+ text: "#171717",
1138
+ muted: "#62665b",
1139
+ subtle: "#8a8f82",
1140
+ border: "#dde0d7",
1141
+ accent: "#b6ff2e",
1142
+ code: "#f1f3ec",
1143
+ dangerSurface: "#fff2f0",
1144
+ dangerBorder: "#ffc9c2",
1145
+ dangerText: "#9f251b"
1146
+ },
1147
+ font: "Arial, 'Helvetica Neue', Helvetica, sans-serif",
1148
+ mono: "'Courier New', Courier, monospace",
1149
+ radius: { card: "12px", control: "6px" },
1150
+ width: "600px"
1151
+ };
1152
+ function escapeHtml(value) {
1153
+ return value.replace(/[&<>'"]/g, (character) => ({
1154
+ "&": "&amp;",
1155
+ "<": "&lt;",
1156
+ ">": "&gt;",
1157
+ "'": "&#39;",
1158
+ '"': "&quot;"
1159
+ })[character]);
1160
+ }
1161
+ function safeHttpUrl(value) {
1162
+ try {
1163
+ const url = new URL(value);
1164
+ return url.protocol === "https:" || url.protocol === "http:" ? escapeHtml(url.toString()) : void 0;
1165
+ } catch {
1166
+ return void 0;
1167
+ }
1168
+ }
1169
+ function heading(content) {
1170
+ 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>`;
1171
+ }
1172
+ function paragraph(content, margin = "0 0 16px") {
1173
+ return `<p style="margin:${margin};font-family:${emailTokens.font};font-size:15px;line-height:1.6;color:${emailTokens.colors.muted}">${escapeHtml(content)}</p>`;
1174
+ }
1175
+ function sectionLabel(content) {
1176
+ 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>`;
1177
+ }
1178
+ function divider() {
1179
+ 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>`;
1180
+ }
1181
+ function spacer(height = 16) {
1182
+ return table(row("&nbsp;", `height:${height}px;font-size:0;line-height:0`));
1183
+ }
1184
+ function row(content, cellStyle = "") {
1185
+ return `<tr><td style="${cellStyle}">${content}</td></tr>`;
1186
+ }
1187
+ function table(content, style = "width:100%") {
1188
+ return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="${style}">${content}</table>`;
1189
+ }
1190
+ function detailBox(content, monospace = false) {
1191
+ const font = monospace ? emailTokens.mono : emailTokens.font;
1192
+ 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}`);
1193
+ }
1194
+ function ctaButton(label, href) {
1195
+ const safeHref = safeHttpUrl(href);
1196
+ if (!safeHref) return "";
1197
+ return table(row(
1198
+ `<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>`,
1199
+ "padding:8px 0 24px"
1200
+ ), "width:auto");
1201
+ }
1202
+ function alertBox(content) {
1203
+ 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}`);
1204
+ }
1205
+ function layout({ preheader, title, content, footer }) {
1206
+ return `<!doctype html>
1207
+ <html lang="en">
1208
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
1209
+ <body style="margin:0;padding:0;background:${emailTokens.colors.canvas}">
1210
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent">${escapeHtml(preheader)}</div>
1211
+ ${table(row(
1212
+ table(
1213
+ 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"),
1214
+ `width:100%;max-width:${emailTokens.width};background:${emailTokens.colors.surface};border:1px solid ${emailTokens.colors.border};border-radius:${emailTokens.radius.card};overflow:hidden`
1215
+ ),
1216
+ "padding:32px 12px"
1217
+ ), `width:100%;background:${emailTokens.colors.canvas}`)}
1218
+ </body>
1219
+ </html>`;
1220
+ }
1221
+
1043
1222
  // src/services/email.service.ts
1044
1223
  var _devSend = null;
1045
1224
  var _devSendPromise = null;
@@ -1083,77 +1262,24 @@ function buildWelcomeEmail(config, args) {
1083
1262
  const custom = config.email?.templates?.welcome?.(args);
1084
1263
  return {
1085
1264
  subject: custom?.subject ?? "Welcome \u2014 your account is ready",
1086
- html: custom?.html ?? `
1087
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f9fafb;table-layout:fixed">
1088
- <tr>
1089
- <td align="center" style="padding:40px 16px">
1090
- <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">
1091
- <tr>
1092
- <td style="padding:32px 32px 0">
1093
- <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>
1094
- <h1 style="margin:0 0 24px;font-size:22px;font-weight:700;color:#111827;font-family:sans-serif">Welcome!</h1>
1095
- </td>
1096
- </tr>
1097
- <tr>
1098
- <td style="padding:0 32px">
1099
- <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>
1100
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f3f4f6;border-radius:6px;table-layout:fixed">
1101
- <tr>
1102
- <td style="padding:12px 16px;font-size:14px;font-weight:600;color:#111827;font-family:sans-serif;word-break:break-all">
1103
- ${args.email}
1104
- </td>
1105
- </tr>
1106
- </table>
1107
- </td>
1108
- </tr>
1109
- <tr>
1110
- <td style="padding:32px">
1111
- <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>
1112
- </td>
1113
- </tr>
1114
- </table>
1115
- </td>
1116
- </tr>
1117
- </table>`
1265
+ html: custom?.html ?? layout({
1266
+ preheader: "Your Dyrected account is ready.",
1267
+ title: "Welcome \u2014 your account is ready",
1268
+ content: `${paragraph("Your account has been created. You can now log in with:")}${detailBox(args.email)}`,
1269
+ footer: "If you didn't create this account, you can safely ignore this email."
1270
+ })
1118
1271
  };
1119
1272
  }
1120
1273
  function buildInviteEmail(config, args) {
1121
1274
  const custom = config.email?.templates?.invite?.(args);
1122
1275
  return {
1123
1276
  subject: custom?.subject ?? "You've been invited",
1124
- html: custom?.html ?? `
1125
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f9fafb;table-layout:fixed">
1126
- <tr>
1127
- <td align="center" style="padding:40px 16px">
1128
- <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">
1129
- <tr>
1130
- <td style="padding:32px 32px 0">
1131
- <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>
1132
- <h1 style="margin:0 0 24px;font-size:22px;font-weight:700;color:#111827;font-family:sans-serif">You've been invited</h1>
1133
- </td>
1134
- </tr>
1135
- <tr>
1136
- <td style="padding:0 32px">
1137
- ${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>` : ""}
1138
- <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>
1139
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f3f4f6;border-radius:6px;table-layout:fixed">
1140
- <tr>
1141
- <td style="padding:12px 16px;font-family:monospace;font-size:12px;color:#374151;word-break:break-all;white-space:normal;line-height:1.4">
1142
- ${args.token}
1143
- </td>
1144
- </tr>
1145
- </table>
1146
- </td>
1147
- </tr>
1148
- <tr>
1149
- <td style="padding:32px">
1150
- <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>
1151
- </td>
1152
- </tr>
1153
- </table>
1154
- </td>
1155
- </tr>
1156
- </table>`
1277
+ html: custom?.html ?? layout({
1278
+ preheader: "You've been invited to join a Dyrected account.",
1279
+ title: "You've been invited",
1280
+ 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)}`,
1281
+ footer: "If you weren't expecting this invitation, you can safely ignore this email."
1282
+ })
1157
1283
  };
1158
1284
  }
1159
1285
  function buildResetPasswordEmail(config, args) {
@@ -1161,95 +1287,24 @@ function buildResetPasswordEmail(config, args) {
1161
1287
  const resetLink = args.url;
1162
1288
  return {
1163
1289
  subject: custom?.subject ?? "Reset your password",
1164
- html: custom?.html ?? `
1165
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f9fafb;table-layout:fixed">
1166
- <tr>
1167
- <td align="center" style="padding:40px 16px">
1168
- <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">
1169
- <tr>
1170
- <td style="padding:32px 32px 0">
1171
- <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>
1172
- <h1 style="margin:0 0 24px;font-size:22px;font-weight:700;color:#111827;font-family:sans-serif">Reset your password</h1>
1173
- </td>
1174
- </tr>
1175
- <tr>
1176
- <td style="padding:0 32px">
1177
- <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>
1178
- ${resetLink ? `
1179
- <table cellpadding="0" cellspacing="0" border="0" style="margin-bottom:24px">
1180
- <tr>
1181
- <td style="border-radius:6px;background-color:#111827">
1182
- <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">
1183
- Reset Password
1184
- </a>
1185
- </td>
1186
- </tr>
1187
- </table>
1188
- ` : ""}
1189
- <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>
1190
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f3f4f6;border-radius:6px;table-layout:fixed">
1191
- <tr>
1192
- <td style="padding:12px 16px;font-family:monospace;font-size:12px;color:#374151;word-break:break-all;white-space:normal;line-height:1.4">
1193
- ${args.token}
1194
- </td>
1195
- </tr>
1196
- </table>
1197
- </td>
1198
- </tr>
1199
- <tr>
1200
- <td style="padding:32px">
1201
- <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>
1202
- </td>
1203
- </tr>
1204
- </table>
1205
- </td>
1206
- </tr>
1207
- </table>`
1290
+ html: custom?.html ?? layout({
1291
+ preheader: "Reset your Dyrected password.",
1292
+ title: "Reset your password",
1293
+ 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)}`,
1294
+ footer: "If you didn't request a password reset, you can safely ignore this email."
1295
+ })
1208
1296
  };
1209
1297
  }
1210
1298
  function buildPasswordChangedEmail(config, args) {
1211
1299
  const custom = config.email?.templates?.passwordChanged?.(args);
1212
1300
  return {
1213
1301
  subject: custom?.subject ?? "Your password has been changed",
1214
- html: custom?.html ?? `
1215
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f9fafb;table-layout:fixed">
1216
- <tr>
1217
- <td align="center" style="padding:40px 16px">
1218
- <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">
1219
- <tr>
1220
- <td style="padding:32px 32px 0">
1221
- <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>
1222
- <h1 style="margin:0 0 24px;font-size:22px;font-weight:700;color:#111827;font-family:sans-serif">Password changed</h1>
1223
- </td>
1224
- </tr>
1225
- <tr>
1226
- <td style="padding:0 32px">
1227
- <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>
1228
- <table cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f3f4f6;border-radius:6px;table-layout:fixed">
1229
- <tr>
1230
- <td style="padding:12px 16px;font-size:14px;font-weight:600;color:#111827;font-family:sans-serif;word-break:break-all">
1231
- ${args.email}
1232
- </td>
1233
- </tr>
1234
- </table>
1235
- <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">
1236
- <tr>
1237
- <td style="padding:12px 16px;font-size:13px;color:#b91c1c;line-height:1.5;font-family:sans-serif">
1238
- If you did not make this change, please contact support immediately.
1239
- </td>
1240
- </tr>
1241
- </table>
1242
- </td>
1243
- </tr>
1244
- <tr>
1245
- <td style="padding:32px">
1246
- <p style="margin:0;font-size:12px;color:#9ca3af;font-family:sans-serif">This is an automated security notification.</p>
1247
- </td>
1248
- </tr>
1249
- </table>
1250
- </td>
1251
- </tr>
1252
- </table>`
1302
+ html: custom?.html ?? layout({
1303
+ preheader: "Your Dyrected password was changed.",
1304
+ title: "Password changed",
1305
+ 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.")}`,
1306
+ footer: "This is an automated security notification."
1307
+ })
1253
1308
  };
1254
1309
  }
1255
1310
 
@@ -1623,328 +1678,6 @@ function optionalAuth() {
1623
1678
  };
1624
1679
  }
1625
1680
 
1626
- // src/utils/openapi.ts
1627
- function generateOpenApi(config) {
1628
- const spec = {
1629
- openapi: "3.0.0",
1630
- info: {
1631
- title: "Dyrected API",
1632
- version: "1.0.0",
1633
- description: "Automatically generated OpenAPI specification for the Dyrected project."
1634
- },
1635
- components: {
1636
- schemas: {},
1637
- securitySchemes: {
1638
- ApiKeyAuth: {
1639
- type: "apiKey",
1640
- in: "header",
1641
- name: "x-api-key"
1642
- }
1643
- }
1644
- },
1645
- paths: {},
1646
- security: [{ ApiKeyAuth: [] }]
1647
- };
1648
- for (const collection of config.collections) {
1649
- spec.components.schemas[collection.slug] = collectionToSchema(collection);
1650
- }
1651
- for (const global of config.globals) {
1652
- spec.components.schemas[global.slug] = globalToSchema(global);
1653
- }
1654
- for (const collection of config.collections) {
1655
- const slug = collection.slug;
1656
- const path = `/api/collections/${slug}`;
1657
- const labels = collection.labels || { singular: slug, plural: `${slug}s` };
1658
- spec.paths[path] = {
1659
- get: {
1660
- tags: ["Collections"],
1661
- summary: `Find ${labels.plural}`,
1662
- parameters: [
1663
- { name: "limit", in: "query", schema: { type: "integer", default: 10 } },
1664
- { name: "page", in: "query", schema: { type: "integer", default: 1 } },
1665
- { name: "where", in: "query", schema: { type: "string" }, description: "JSON filter" },
1666
- { name: "sort", in: "query", schema: { type: "string" }, description: "Sort field (e.g. -createdAt)" }
1667
- ],
1668
- responses: {
1669
- 200: {
1670
- description: "Success",
1671
- content: {
1672
- "application/json": {
1673
- schema: {
1674
- type: "object",
1675
- properties: {
1676
- docs: { type: "array", items: { $ref: `#/components/schemas/${slug}` } },
1677
- total: { type: "integer" },
1678
- limit: { type: "integer" },
1679
- page: { type: "integer" }
1680
- }
1681
- }
1682
- }
1683
- }
1684
- }
1685
- }
1686
- },
1687
- post: {
1688
- tags: ["Collections"],
1689
- summary: `Create ${labels.singular}`,
1690
- requestBody: {
1691
- required: true,
1692
- content: {
1693
- "application/json": {
1694
- schema: { $ref: `#/components/schemas/${slug}` }
1695
- }
1696
- }
1697
- },
1698
- responses: {
1699
- 201: {
1700
- description: "Created",
1701
- content: {
1702
- "application/json": {
1703
- schema: { $ref: `#/components/schemas/${slug}` }
1704
- }
1705
- }
1706
- }
1707
- }
1708
- }
1709
- };
1710
- spec.paths[`${path}/{id}`] = {
1711
- get: {
1712
- tags: ["Collections"],
1713
- summary: `Get a single ${labels.singular}`,
1714
- parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
1715
- responses: {
1716
- 200: {
1717
- description: "Success",
1718
- content: {
1719
- "application/json": {
1720
- schema: { $ref: `#/components/schemas/${slug}` }
1721
- }
1722
- }
1723
- }
1724
- }
1725
- },
1726
- patch: {
1727
- tags: ["Collections"],
1728
- summary: `Update ${labels.singular}`,
1729
- parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
1730
- requestBody: {
1731
- required: true,
1732
- content: {
1733
- "application/json": {
1734
- schema: { $ref: `#/components/schemas/${slug}` }
1735
- }
1736
- }
1737
- },
1738
- responses: {
1739
- 200: {
1740
- description: "Updated",
1741
- content: {
1742
- "application/json": {
1743
- schema: { $ref: `#/components/schemas/${slug}` }
1744
- }
1745
- }
1746
- }
1747
- }
1748
- },
1749
- delete: {
1750
- tags: ["Collections"],
1751
- summary: `Delete ${labels.singular}`,
1752
- parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
1753
- responses: {
1754
- 204: { description: "Deleted" }
1755
- }
1756
- }
1757
- };
1758
- }
1759
- for (const global of config.globals) {
1760
- const slug = global.slug;
1761
- const path = `/api/globals/${slug}`;
1762
- spec.paths[path] = {
1763
- get: {
1764
- tags: ["Globals"],
1765
- summary: `Get ${global.label || slug}`,
1766
- responses: {
1767
- 200: {
1768
- description: "Success",
1769
- content: {
1770
- "application/json": {
1771
- schema: { $ref: `#/components/schemas/${slug}` }
1772
- }
1773
- }
1774
- }
1775
- }
1776
- },
1777
- patch: {
1778
- tags: ["Globals"],
1779
- summary: `Update ${global.label || slug}`,
1780
- requestBody: {
1781
- required: true,
1782
- content: {
1783
- "application/json": {
1784
- schema: { $ref: `#/components/schemas/${slug}` }
1785
- }
1786
- }
1787
- },
1788
- responses: {
1789
- 200: {
1790
- description: "Updated",
1791
- content: {
1792
- "application/json": {
1793
- schema: { $ref: `#/components/schemas/${slug}` }
1794
- }
1795
- }
1796
- }
1797
- }
1798
- }
1799
- };
1800
- }
1801
- if (config.storage) {
1802
- spec.paths["/api/media"] = {
1803
- get: {
1804
- tags: ["Media"],
1805
- summary: "List Media",
1806
- responses: {
1807
- 200: {
1808
- description: "Success",
1809
- content: {
1810
- "application/json": {
1811
- schema: {
1812
- type: "object",
1813
- properties: {
1814
- docs: { type: "array", items: { type: "object", additionalProperties: true } }
1815
- }
1816
- }
1817
- }
1818
- }
1819
- }
1820
- }
1821
- },
1822
- post: {
1823
- tags: ["Media"],
1824
- summary: "Upload Media",
1825
- requestBody: {
1826
- content: {
1827
- "multipart/form-data": {
1828
- schema: {
1829
- type: "object",
1830
- properties: {
1831
- file: { type: "string", format: "binary" }
1832
- }
1833
- }
1834
- }
1835
- }
1836
- },
1837
- responses: {
1838
- 201: { description: "Uploaded" }
1839
- }
1840
- }
1841
- };
1842
- }
1843
- return spec;
1844
- }
1845
- function collectionToSchema(collection) {
1846
- const { properties, required } = fieldsToProperties(collection.fields);
1847
- return {
1848
- type: "object",
1849
- properties: {
1850
- id: { type: "string" },
1851
- createdAt: { type: "string", format: "date-time" },
1852
- updatedAt: { type: "string", format: "date-time" },
1853
- ...properties
1854
- },
1855
- required: ["id", ...required]
1856
- };
1857
- }
1858
- function globalToSchema(global) {
1859
- const { properties, required } = fieldsToProperties(global.fields);
1860
- return {
1861
- type: "object",
1862
- properties,
1863
- required
1864
- };
1865
- }
1866
- function fieldsToProperties(fields) {
1867
- const props = {};
1868
- const required = [];
1869
- for (const field of fields) {
1870
- if (!field.name || field.type === "join" || field.type === "row") continue;
1871
- props[field.name] = fieldToSchema(field);
1872
- if (field.required) {
1873
- required.push(field.name);
1874
- }
1875
- }
1876
- return { properties: props, required };
1877
- }
1878
- function fieldToSchema(field) {
1879
- let schema = {};
1880
- switch (field.type) {
1881
- case "text":
1882
- case "textarea":
1883
- case "email":
1884
- case "url":
1885
- schema = { type: "string" };
1886
- break;
1887
- case "number":
1888
- schema = { type: "number" };
1889
- break;
1890
- case "boolean":
1891
- schema = { type: "boolean" };
1892
- break;
1893
- case "date":
1894
- schema = { type: "string", format: "date-time" };
1895
- break;
1896
- case "select":
1897
- case "radio":
1898
- schema = { type: "string", enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0 };
1899
- break;
1900
- case "multiSelect":
1901
- schema = {
1902
- type: "array",
1903
- items: { type: "string", enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0 }
1904
- };
1905
- break;
1906
- case "relationship":
1907
- schema = { type: "string", description: `ID of a ${field.relationTo} record` };
1908
- break;
1909
- case "object": {
1910
- const { properties, required } = fieldsToProperties(field.fields || []);
1911
- schema = { type: "object", properties, required };
1912
- break;
1913
- }
1914
- case "array": {
1915
- const { properties, required } = fieldsToProperties(field.fields || []);
1916
- schema = { type: "array", items: { type: "object", properties, required } };
1917
- break;
1918
- }
1919
- case "json":
1920
- case "richText":
1921
- schema = { type: "object", additionalProperties: true };
1922
- break;
1923
- case "blocks":
1924
- schema = {
1925
- type: "array",
1926
- items: {
1927
- oneOf: field.blocks?.map((block) => {
1928
- const { properties, required } = fieldsToProperties(block.fields);
1929
- return {
1930
- type: "object",
1931
- properties: {
1932
- blockType: { type: "string", enum: [block.slug] },
1933
- ...properties
1934
- },
1935
- required: ["blockType", ...required]
1936
- };
1937
- })
1938
- }
1939
- };
1940
- break;
1941
- default:
1942
- schema = { type: "string" };
1943
- }
1944
- if (field.label) schema.description = field.label;
1945
- return schema;
1946
- }
1947
-
1948
1681
  // src/utils/swagger.ts
1949
1682
  function getSwaggerHtml(specUrl = "/api/openapi.json") {
1950
1683
  return `
@@ -1994,20 +1727,6 @@ function getSwaggerHtml(specUrl = "/api/openapi.json") {
1994
1727
  `;
1995
1728
  }
1996
1729
 
1997
- // src/auth/jexl.ts
1998
- import jexl from "jexl";
1999
- async function evaluateAccess(expression, context) {
2000
- if (expression === void 0 || expression === null) return false;
2001
- if (typeof expression === "boolean") return expression;
2002
- try {
2003
- const result = await jexl.eval(expression, context);
2004
- return !!result;
2005
- } catch (err) {
2006
- console.error("[dyrected/core] Jexl evaluation failed:", err);
2007
- return false;
2008
- }
2009
- }
2010
-
2011
1730
  // src/router.ts
2012
1731
  function accessGate(target, action) {
2013
1732
  return async (c, next) => {
@@ -2118,7 +1837,22 @@ function registerRoutes(app, config) {
2118
1837
  }))),
2119
1838
  upload: !!col.upload,
2120
1839
  auth: !!col.auth,
2121
- admin: col.admin
1840
+ admin: col.admin,
1841
+ workflow: col.workflow ? {
1842
+ initialState: col.workflow.initialState,
1843
+ draftState: col.workflow.draftState,
1844
+ states: col.workflow.states,
1845
+ transitions: col.workflow.transitions.map((t) => ({
1846
+ name: t.name,
1847
+ label: t.label,
1848
+ from: t.from,
1849
+ to: t.to,
1850
+ requiredCapabilities: t.requiredCapabilities,
1851
+ requireComment: t.requireComment,
1852
+ unpublish: t.unpublish
1853
+ })),
1854
+ roles: col.workflow.roles
1855
+ } : void 0
2122
1856
  })));
2123
1857
  const filteredGlobals = await Promise.all(globals.filter((glb) => !siteId || glb.shared || !glb.siteId || glb.siteId === siteId).map(async (glb) => ({
2124
1858
  slug: glb.slug,
@@ -2312,6 +2046,10 @@ function registerRoutes(app, config) {
2312
2046
  if (collection.auth) {
2313
2047
  app.post(`${path}/:id/change-password`, requireAuth(), (c) => controller.changePassword(c));
2314
2048
  }
2049
+ if (collection.workflow) {
2050
+ app.post(`${path}/:id/transitions/:transition`, requireAuth(), (c) => controller.transition(c));
2051
+ app.get(`${path}/:id/workflow-history`, requireAuth(), (c) => controller.workflowHistory(c));
2052
+ }
2315
2053
  }
2316
2054
  for (const global of config.globals) {
2317
2055
  const path = `/api/globals/${global.slug}`;
@@ -2323,6 +2061,41 @@ function registerRoutes(app, config) {
2323
2061
  const previewController = new PreviewController();
2324
2062
  app.post("/api/preview-token", requireAuth(), (c) => previewController.createToken(c));
2325
2063
  app.get("/api/preview-data", (c) => previewController.getData(c));
2064
+ app.all("/api/collections/:slug/:id/*", requireAuth(), async (c) => {
2065
+ const slug = c.req.param("slug");
2066
+ const id = c.req.param("id");
2067
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
2068
+ const config2 = c.get("config");
2069
+ if (config2.collections.some((col) => col.slug === slug)) {
2070
+ return c.json({ message: "Not Found" }, 404);
2071
+ }
2072
+ if (!config2.onSchemaFetch || !siteId) {
2073
+ return c.json({ message: `Collection "${slug}" not found` }, 404);
2074
+ }
2075
+ const dynamic = await config2.onSchemaFetch(siteId);
2076
+ const collection = dynamic.collections?.find((col) => col.slug === slug);
2077
+ if (!collection?.workflow) {
2078
+ return c.json({ message: `Collection "${slug}" not found or has no workflow` }, 404);
2079
+ }
2080
+ const controller = new CollectionController(collection);
2081
+ const rawPath = c.req.path;
2082
+ const method = c.req.method;
2083
+ const transitionMatch = rawPath.match(/\/transitions\/([^/]+)$/);
2084
+ if (transitionMatch && method === "POST") {
2085
+ c.req.raw.__transition = transitionMatch[1];
2086
+ const origParam = c.req.param.bind(c.req);
2087
+ c.req.param = (key) => {
2088
+ if (key === "transition") return transitionMatch[1];
2089
+ if (key === "id") return id;
2090
+ return origParam(key);
2091
+ };
2092
+ return controller.transition(c);
2093
+ }
2094
+ if (rawPath.endsWith("/workflow-history") && method === "GET") {
2095
+ return controller.workflowHistory(c);
2096
+ }
2097
+ return c.json({ message: "Not Found" }, 404);
2098
+ });
2326
2099
  app.all("/api/collections/:slug/:id?", async (c) => {
2327
2100
  const slug = c.req.param("slug");
2328
2101
  const id = c.req.param("id");