@dyrected/core 2.5.30 → 2.5.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.cjs CHANGED
@@ -535,6 +535,20 @@ function createReadonlyDb(db) {
535
535
  });
536
536
  }
537
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
+
538
552
  // src/workflows.ts
539
553
  var WORKFLOW_HISTORY_COLLECTION = "__workflow_history";
540
554
  var LIFECYCLE_EVENTS_COLLECTION = "__lifecycle_events";
@@ -550,6 +564,14 @@ function workflowCapabilities(workflow, user) {
550
564
  }
551
565
  return capabilities;
552
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
+ }
553
575
  function availableWorkflowTransitions(workflow, state, user) {
554
576
  const capabilities = workflowCapabilities(workflow, user);
555
577
  return workflow.transitions.filter((transition) => {
@@ -567,7 +589,7 @@ function materializeWorkflowDocument(doc, workflow, user) {
567
589
  const meta = doc.__workflow;
568
590
  if (!meta) return doc;
569
591
  const { __published, __workflow, ...working } = doc;
570
- if (!user) {
592
+ if (!canViewWorkflowDraft(workflow, user)) {
571
593
  if (!__published || typeof __published !== "object") return null;
572
594
  return { id: doc.id, ...__published, _workflow: publicMetadata(meta) };
573
595
  }
@@ -711,12 +733,18 @@ async function transitionWorkflow(args) {
711
733
  events.push(createLifecycleEvent({ name: "entry.unpublished", collection: collection.slug, documentId: id, actorId: user?.sub, payload: { revision: meta.publishedRevision } }));
712
734
  }
713
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
+ }
714
742
  const now = (/* @__PURE__ */ new Date()).toISOString();
715
- const { __published: _published, __workflow: _workflow, id: _id, ...working } = original;
743
+ const { __published: _published, __workflow: _workflow, id: _id, ...working } = locked;
716
744
  const nextMeta = {
717
- ...meta,
745
+ ...lockedMeta,
718
746
  state: transition.to,
719
- ...targetState.published ? { publishedRevision: meta.revision, publishedAt: now, publishedBy: user?.sub } : {},
747
+ ...targetState.published ? { publishedRevision: lockedMeta.revision, publishedAt: now, publishedBy: user?.sub } : {},
720
748
  ...transition.unpublish ? { publishedRevision: void 0, publishedAt: void 0, publishedBy: void 0 } : {}
721
749
  };
722
750
  const data = { __workflow: nextMeta };
@@ -725,11 +753,11 @@ async function transitionWorkflow(args) {
725
753
  const next = await tx.update({ collection: collection.slug, id, data });
726
754
  await tx.create({
727
755
  collection: WORKFLOW_HISTORY_COLLECTION,
728
- data: { collection: collection.slug, documentId: id, transition: transition.name, from: meta.state, to: transition.to, revision: meta.revision, comment: comment ?? null, actorId: user?.sub ?? null, createdAt: now }
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 }
729
757
  });
730
758
  for (const event of events) await persistEvent(tx, event);
731
759
  if (collection.audit) {
732
- 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: meta.state, to: transition.to, revision: meta.revision }) } });
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 }) } });
733
761
  }
734
762
  return next;
735
763
  });
@@ -788,7 +816,7 @@ var CollectionController = class {
788
816
  if (beforeReadResult !== void 0) {
789
817
  where = beforeReadResult;
790
818
  }
791
- if (this.collection.workflow && !user) {
819
+ if (this.collection.workflow && !canViewWorkflowDraft(this.collection.workflow, user)) {
792
820
  where = where ? { AND: [where, { __published: { exists: true } }] } : { __published: { exists: true } };
793
821
  }
794
822
  let result = await db.find({
@@ -1081,9 +1109,28 @@ var CollectionController = class {
1081
1109
  const config = c.get("config");
1082
1110
  if (!config.db) return c.json({ message: "Database not configured" }, 500);
1083
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
+ }
1084
1131
  const result = await config.db.find({
1085
1132
  collection: WORKFLOW_HISTORY_COLLECTION,
1086
- where: { collection: { equals: this.collection.slug }, documentId: { equals: c.req.param("id") } },
1133
+ where: { collection: { equals: this.collection.slug }, documentId: { equals: documentId } },
1087
1134
  sort: "-createdAt",
1088
1135
  limit: Math.min(Number(c.req.query("limit")) || 50, 100)
1089
1136
  });
@@ -2115,7 +2162,76 @@ function generateOpenApi(config) {
2115
2162
  description: "Automatically generated OpenAPI specification for the Dyrected project."
2116
2163
  },
2117
2164
  components: {
2118
- 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
+ },
2119
2235
  securitySchemes: {
2120
2236
  ApiKeyAuth: {
2121
2237
  type: "apiKey",
@@ -2127,6 +2243,98 @@ function generateOpenApi(config) {
2127
2243
  paths: {},
2128
2244
  security: [{ ApiKeyAuth: [] }]
2129
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
+ };
2130
2338
  for (const collection of config.collections) {
2131
2339
  spec.components.schemas[collection.slug] = collectionToSchema(collection);
2132
2340
  }
@@ -2142,10 +2350,28 @@ function generateOpenApi(config) {
2142
2350
  tags: ["Collections"],
2143
2351
  summary: `Find ${labels.plural}`,
2144
2352
  parameters: [
2145
- { name: "limit", in: "query", schema: { type: "integer", default: 10 } },
2146
- { name: "page", in: "query", schema: { type: "integer", default: 1 } },
2147
- { name: "where", in: "query", schema: { type: "string" }, description: "JSON filter" },
2148
- { name: "sort", in: "query", schema: { type: "string" }, description: "Sort field (e.g. -createdAt)" }
2353
+ {
2354
+ name: "limit",
2355
+ in: "query",
2356
+ schema: { type: "integer", default: 10 }
2357
+ },
2358
+ {
2359
+ name: "page",
2360
+ in: "query",
2361
+ schema: { type: "integer", default: 1 }
2362
+ },
2363
+ {
2364
+ name: "where",
2365
+ in: "query",
2366
+ schema: { type: "string" },
2367
+ description: "JSON filter"
2368
+ },
2369
+ {
2370
+ name: "sort",
2371
+ in: "query",
2372
+ schema: { type: "string" },
2373
+ description: "Sort field (e.g. -createdAt)"
2374
+ }
2149
2375
  ],
2150
2376
  responses: {
2151
2377
  200: {
@@ -2155,7 +2381,10 @@ function generateOpenApi(config) {
2155
2381
  schema: {
2156
2382
  type: "object",
2157
2383
  properties: {
2158
- docs: { type: "array", items: { $ref: `#/components/schemas/${slug}` } },
2384
+ docs: {
2385
+ type: "array",
2386
+ items: { $ref: `#/components/schemas/${slug}` }
2387
+ },
2159
2388
  total: { type: "integer" },
2160
2389
  limit: { type: "integer" },
2161
2390
  page: { type: "integer" }
@@ -2193,7 +2422,14 @@ function generateOpenApi(config) {
2193
2422
  get: {
2194
2423
  tags: ["Collections"],
2195
2424
  summary: `Get a single ${labels.singular}`,
2196
- parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
2425
+ parameters: [
2426
+ {
2427
+ name: "id",
2428
+ in: "path",
2429
+ required: true,
2430
+ schema: { type: "string" }
2431
+ }
2432
+ ],
2197
2433
  responses: {
2198
2434
  200: {
2199
2435
  description: "Success",
@@ -2208,7 +2444,14 @@ function generateOpenApi(config) {
2208
2444
  patch: {
2209
2445
  tags: ["Collections"],
2210
2446
  summary: `Update ${labels.singular}`,
2211
- parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
2447
+ parameters: [
2448
+ {
2449
+ name: "id",
2450
+ in: "path",
2451
+ required: true,
2452
+ schema: { type: "string" }
2453
+ }
2454
+ ],
2212
2455
  requestBody: {
2213
2456
  required: true,
2214
2457
  content: {
@@ -2231,12 +2474,288 @@ function generateOpenApi(config) {
2231
2474
  delete: {
2232
2475
  tags: ["Collections"],
2233
2476
  summary: `Delete ${labels.singular}`,
2234
- parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
2477
+ parameters: [
2478
+ {
2479
+ name: "id",
2480
+ in: "path",
2481
+ required: true,
2482
+ schema: { type: "string" }
2483
+ }
2484
+ ],
2235
2485
  responses: {
2236
2486
  204: { description: "Deleted" }
2237
2487
  }
2238
2488
  }
2239
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
+ }
2240
2759
  }
2241
2760
  for (const global of config.globals) {
2242
2761
  const slug = global.slug;
@@ -2279,45 +2798,31 @@ function generateOpenApi(config) {
2279
2798
  }
2280
2799
  }
2281
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
+ };
2282
2808
  }
2283
2809
  if (config.storage) {
2284
- spec.paths["/api/media"] = {
2810
+ spec.paths["/api/media/{filename}"] = {
2285
2811
  get: {
2286
2812
  tags: ["Media"],
2287
- summary: "List Media",
2288
- responses: {
2289
- 200: {
2290
- description: "Success",
2291
- content: {
2292
- "application/json": {
2293
- schema: {
2294
- type: "object",
2295
- properties: {
2296
- docs: { type: "array", items: { type: "object", additionalProperties: true } }
2297
- }
2298
- }
2299
- }
2300
- }
2301
- }
2302
- }
2303
- },
2304
- post: {
2305
- tags: ["Media"],
2306
- summary: "Upload Media",
2307
- requestBody: {
2308
- content: {
2309
- "multipart/form-data": {
2310
- schema: {
2311
- type: "object",
2312
- properties: {
2313
- file: { type: "string", format: "binary" }
2314
- }
2315
- }
2316
- }
2813
+ summary: "Serve a stored file",
2814
+ security: [],
2815
+ parameters: [
2816
+ {
2817
+ name: "filename",
2818
+ in: "path",
2819
+ required: true,
2820
+ schema: { type: "string" }
2317
2821
  }
2318
- },
2822
+ ],
2319
2823
  responses: {
2320
- 201: { description: "Uploaded" }
2824
+ 200: { description: "Stored file bytes" },
2825
+ 404: { description: "File not found" }
2321
2826
  }
2322
2827
  }
2323
2828
  };
@@ -2332,6 +2837,7 @@ function collectionToSchema(collection) {
2332
2837
  id: { type: "string" },
2333
2838
  createdAt: { type: "string", format: "date-time" },
2334
2839
  updatedAt: { type: "string", format: "date-time" },
2840
+ ...collection.workflow ? { _workflow: { $ref: "#/components/schemas/WorkflowMetadata" } } : {},
2335
2841
  ...properties
2336
2842
  },
2337
2843
  required: ["id", ...required]
@@ -2349,7 +2855,13 @@ function fieldsToProperties(fields) {
2349
2855
  const props = {};
2350
2856
  const required = [];
2351
2857
  for (const field of fields) {
2352
- if (!field.name || field.type === "join" || field.type === "row") continue;
2858
+ if (field.type === "row") {
2859
+ const nested = fieldsToProperties(field.fields || []);
2860
+ Object.assign(props, nested.properties);
2861
+ required.push(...nested.required);
2862
+ continue;
2863
+ }
2864
+ if (!field.name) continue;
2353
2865
  props[field.name] = fieldToSchema(field);
2354
2866
  if (field.required) {
2355
2867
  required.push(field.name);
@@ -2363,7 +2875,17 @@ function fieldToSchema(field) {
2363
2875
  case "text":
2364
2876
  case "textarea":
2365
2877
  case "email":
2878
+ schema = { type: "string" };
2879
+ break;
2366
2880
  case "url":
2881
+ schema = {
2882
+ oneOf: [
2883
+ { type: "string" },
2884
+ { type: "object", additionalProperties: true }
2885
+ ]
2886
+ };
2887
+ break;
2888
+ case "icon":
2367
2889
  schema = { type: "string" };
2368
2890
  break;
2369
2891
  case "number":
@@ -2373,20 +2895,45 @@ function fieldToSchema(field) {
2373
2895
  schema = { type: "boolean" };
2374
2896
  break;
2375
2897
  case "date":
2898
+ schema = { type: "string", format: "date" };
2899
+ break;
2900
+ case "datetime":
2376
2901
  schema = { type: "string", format: "date-time" };
2377
2902
  break;
2903
+ case "time":
2904
+ schema = { type: "string", format: "time" };
2905
+ break;
2378
2906
  case "select":
2379
2907
  case "radio":
2380
- schema = { type: "string", enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0 };
2908
+ schema = {
2909
+ type: "string",
2910
+ enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0
2911
+ };
2381
2912
  break;
2382
2913
  case "multiSelect":
2383
2914
  schema = {
2384
2915
  type: "array",
2385
- items: { type: "string", enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0 }
2916
+ items: {
2917
+ type: "string",
2918
+ enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0
2919
+ }
2386
2920
  };
2387
2921
  break;
2388
2922
  case "relationship":
2389
- schema = { type: "string", description: `ID of a ${field.relationTo} record` };
2923
+ case "image": {
2924
+ const valueSchema = {
2925
+ type: "string",
2926
+ description: `ID of a ${field.relationTo} record`
2927
+ };
2928
+ schema = field.hasMany ? { type: "array", items: valueSchema } : valueSchema;
2929
+ break;
2930
+ }
2931
+ case "join":
2932
+ schema = {
2933
+ type: "array",
2934
+ readOnly: true,
2935
+ items: { type: "object", additionalProperties: true }
2936
+ };
2390
2937
  break;
2391
2938
  case "object": {
2392
2939
  const { properties, required } = fieldsToProperties(field.fields || []);
@@ -2395,7 +2942,10 @@ function fieldToSchema(field) {
2395
2942
  }
2396
2943
  case "array": {
2397
2944
  const { properties, required } = fieldsToProperties(field.fields || []);
2398
- schema = { type: "array", items: { type: "object", properties, required } };
2945
+ schema = {
2946
+ type: "array",
2947
+ items: { type: "object", properties, required }
2948
+ };
2399
2949
  break;
2400
2950
  }
2401
2951
  case "json":
@@ -2476,20 +3026,6 @@ function getSwaggerHtml(specUrl = "/api/openapi.json") {
2476
3026
  `;
2477
3027
  }
2478
3028
 
2479
- // src/auth/jexl.ts
2480
- var import_jexl = __toESM(require("jexl"), 1);
2481
- async function evaluateAccess(expression, context) {
2482
- if (expression === void 0 || expression === null) return false;
2483
- if (typeof expression === "boolean") return expression;
2484
- try {
2485
- const result = await import_jexl.default.eval(expression, context);
2486
- return !!result;
2487
- } catch (err) {
2488
- console.error("[dyrected/core] Jexl evaluation failed:", err);
2489
- return false;
2490
- }
2491
- }
2492
-
2493
3029
  // src/router.ts
2494
3030
  function accessGate(target, action) {
2495
3031
  return async (c, next) => {