@dyrected/core 2.5.30 → 2.5.33

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/index.cjs CHANGED
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  LIFECYCLE_EVENT_NAMES: () => LIFECYCLE_EVENT_NAMES,
25
25
  WORKFLOW_HISTORY_COLLECTION: () => WORKFLOW_HISTORY_COLLECTION,
26
26
  availableWorkflowTransitions: () => availableWorkflowTransitions,
27
+ canViewWorkflowDraft: () => canViewWorkflowDraft,
27
28
  createLifecycleEvent: () => createLifecycleEvent,
28
29
  createWorkflowDocument: () => createWorkflowDocument,
29
30
  defineCollection: () => defineCollection,
@@ -33,8 +34,7 @@ __export(index_exports, {
33
34
  dispatchPendingLifecycleEvents: () => dispatchPendingLifecycleEvents,
34
35
  executeFieldAfterRead: () => executeFieldAfterRead,
35
36
  executeFieldBeforeChange: () => executeFieldBeforeChange,
36
- generateAIPrompt: () => generateAIPrompt,
37
- generateFreshSetupPrompt: () => generateFreshSetupPrompt,
37
+ generateOpenApi: () => generateOpenApi,
38
38
  initializeWorkflowDocument: () => initializeWorkflowDocument,
39
39
  materializeWorkflowDocument: () => materializeWorkflowDocument,
40
40
  normalizeConfig: () => normalizeConfig,
@@ -57,827 +57,6 @@ var LIFECYCLE_EVENT_NAMES = [
57
57
  "entry.unpublished"
58
58
  ];
59
59
 
60
- // src/utils/setup-prompt.ts
61
- function buildEnvironmentSection(frameworkLabel, isSelfHosted, config) {
62
- const credentialLines = isSelfHosted ? "" : `- Site ID : ${config.siteId}
63
- - API Key : ${config.apiKey}`;
64
- return `
65
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
66
- 1. ENVIRONMENT
67
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
68
- - Framework : ${frameworkLabel || "Detect it"}
69
- - Host Type : ${isSelfHosted ? "Self-Hosted (Local/Private Server)" : "Managed (Dyrected Cloud)"}
70
- - API Base : ${config.baseUrl || "http://localhost:3000"}
71
- ${credentialLines}`;
72
- }
73
- function buildDiagnosticSection() {
74
- return `
75
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
76
- 2. PHASE 0 \u2014 DISCOVERY
77
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
78
- Before writing any code, you MUST ask the user these questions.
79
- Write them exactly as shown below \u2014 in plain language with examples.
80
- Wait for the user to answer ALL of them before proceeding.
81
-
82
- \u2500\u2500\u2500 QUESTION 1 \u2500\u2500\u2500
83
- Ask:
84
- "Do you already have a website built, or are we starting fresh?
85
-
86
- \u2192 Already built: share your project or describe what pages you have
87
- (e.g. Home, About, Services, Contact)
88
- \u2192 Starting fresh: just say 'new site' and describe what the site is for"
89
-
90
- \u2500\u2500\u2500 QUESTION 2 \u2500\u2500\u2500
91
- Ask:
92
- "What kind of content will your client need to update regularly?
93
-
94
- Here are some examples to help you think:
95
- \u2192 Blog posts or news articles
96
- \u2192 Team member profiles (name, photo, bio)
97
- \u2192 Services or product descriptions
98
- \u2192 Testimonials or reviews
99
- \u2192 Event listings
100
- \u2192 FAQs
101
- \u2192 Homepage text (headline, hero image, call-to-action button)
102
-
103
- Just list the ones that apply. You can say things like:
104
- 'They need to update blog posts and team members'"
105
-
106
- \u2500\u2500\u2500 QUESTION 3 \u2500\u2500\u2500
107
- Ask:
108
- "Are there any pages on the site that should NEVER change \u2014 like a
109
- custom-coded page you want to leave exactly as it is?
110
-
111
- Example answer: 'The homepage has a custom animation, leave that alone.
112
- Everything else can be managed from the CMS.'
113
-
114
- If everything should be manageable, just say 'all pages'"
115
-
116
- \u2500\u2500\u2500 QUESTION 4 \u2500\u2500\u2500
117
- Ask:
118
- "What is this website for? Pick the closest description:
119
-
120
- A) A business or agency marketing site (show services, get leads)
121
- B) A blog or content site (publish articles regularly)
122
- C) A SaaS or product site (landing page, pricing, features)
123
- D) A portfolio (show work, case studies)
124
- E) An e-commerce or product catalogue
125
- F) Something else \u2014 describe it in one sentence"
126
-
127
- \u2500\u2500\u2500 IF EXISTING SITE \u2500\u2500\u2500
128
- If the user confirms they have an existing site, also:
129
- - Scan the codebase for all hardcoded text that should be CMS-managed
130
- - Identify repeated data structures that should become collections
131
- - Propose saving current content to a migration/ folder as .json files
132
- BEFORE touching any code
133
- - Report your findings to the user and get confirmation before proceeding
134
-
135
- Do NOT write any implementation code until all questions are answered
136
- and the user has confirmed the content plan.`;
137
- }
138
- function buildConstraintsSection() {
139
- return `
140
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
141
- 3. ARCHITECTURE & CONSTRAINTS
142
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
143
- - API ACCESS : Use client.collection(slug) as the only entry point.
144
- - ZERO-STATE : Always use initialData in all data fetches so the site
145
- renders correctly on first load and never throws during render.
146
- - MARKETING FREEDOM : Use a dynamic pages collection with a catch-all route
147
- so marketing can create and manage pages without a developer.
148
- - BLOCKS DESIGN : Use blocks for flexible page builders. Store as
149
- [{ blockType: 'slug', ...fields }] and switch on blockType
150
- in the frontend renderer.
151
- - DATA SAFETY : Never overwrite or drop existing content or pages.
152
- Preserve everything before making changes.
153
- - RESILIENCE : If Dyrected backend is unreachable, fall back to
154
- initialData and show stale content \u2014 never an error page.
155
- All relationship fields must handle null gracefully.
156
- Every block renderer must have a default fallback case.
157
- - AUTO-SEEDING : Use initialData: [...] in CollectionConfig or GlobalConfig
158
- to automatically populate the database on first run.
159
- This is great for demo content or default settings.`;
160
- }
161
- function buildSchemaRulesSection() {
162
- return `
163
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
164
- 4. SCHEMA EVOLUTION RULES
165
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
166
- - Never drop existing fields from the schema. Mark unused fields as deprecated only.
167
- - All new fields must have a defaultValue.
168
- - Use renameTo: 'oldName' to lazily migrate data when renaming fields.
169
- - Use promoted: true for fields that need high-performance SQL indexing or unique constraints.
170
- - For Cloud deployments, run npx @dyrected/cli sync:schema after every config change. Self-hosted deployments sync automatically on startup.
171
- `;
172
- }
173
- function buildDoNotSection() {
174
- return `
175
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
176
- 5. DO NOT
177
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
178
- - Do NOT use client.collections \u2014 always use client.collection(slug).
179
- - Do NOT add custom auth middleware to the admin route.
180
- Dyrected handles admin authentication internally. Do not wrap,
181
- protect, or redirect the admin route yourself.
182
- - Do NOT use renderAdminUI in a Nuxt.js project. Use the DyrectedAdmin
183
- component which is auto-imported by @dyrected/nuxt.
184
- - Do NOT modify or overwrite existing pages without first preserving their data.
185
- - Do NOT drop, rename, or remove fields from an existing schema.
186
- - Do NOT integrate blog posts or testimonials unless explicitly requested.
187
- - Do NOT skip the diagnostic or discovery phase.`;
188
- }
189
- function buildTechnicalReferenceSection() {
190
- return `
191
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
192
- 6. TECHNICAL REFERENCE
193
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
194
- Use defineCollection, defineGlobal, and defineConfig from '@dyrected/core'.
195
-
196
- FIELD TYPES:
197
- - Primitive : text | textarea | richText | number | boolean | date | email | url | json | image
198
- - Choice : select | multiSelect (requires options: [{ label, value }])
199
- - Structural : array | object (requires nested fields: [...])
200
- - Relation : relationship (requires relationTo: '<slug>')
201
- - Media : relationship to an upload collection (e.g. 'media')
202
- - Blocks : blocks (requires blocks: [{ slug, labels, fields }])
203
-
204
- COLLECTION OPTIONS:
205
- - upload: true \u2014 media library with file upload support
206
- - auth: true \u2014 adds login/me endpoints; password field is auto-managed
207
- - audit: true \u2014 enables activity logging
208
- - admin.icon \u2014 valid Lucide component name for sidebar navigation (e.g. 'Newspaper')
209
- - admin.useAsTitle \u2014 field used as display title in admin list view
210
- - admin.group \u2014 groups collection under a sidebar heading
211
- - admin.hidden \u2014 hides collection from the sidebar (internal/system use)
212
-
213
- FIELD OPTIONS:
214
- - label \u2014 user-friendly display name (REQUIRED for all fields)
215
- - required \u2014 validation
216
- - unique \u2014 database-level uniqueness constraint
217
- - hasMany \u2014 allow multiple values (for relationship, select, image)
218
- - defaultValue \u2014 fallback value (required on all new fields added to existing schemas)
219
- - promoted \u2014 extracts field to a real SQL column for native indexing (SQL adapters only)
220
- - renameTo \u2014 name of the old field key to migrate data from (lazy migration)
221
- - admin.condition \u2014 Jexl expression string to conditionally show/hide field
222
- e.g. "status == \\"published\\""
223
- - admin.readOnly \u2014 display only, not editable
224
- - admin.hidden \u2014 hidden from editor UI entirely
225
- - hooks.beforeChange \u2014 [async (value) => newValue] transform before save
226
- - hooks.afterRead \u2014 [async (value) => newValue] transform after read
227
-
228
- GLOBALS:
229
- Use defineGlobal for single-instance documents like site settings or navigation.
230
- Access via client.global(slug).get() and client.global(slug).update(data).
231
-
232
- BLOCKS:
233
- Blocks are stored as [{ blockType: '<slug>', ...fields }].
234
- The admin renders a drag-and-drop block editor automatically.
235
- On the frontend, iterate the array and switch on block.blockType.
236
- Always include a default case in your switch for unknown block types.
237
-
238
- COMPLETE SCHEMA EXAMPLE:
239
- \`\`\`ts
240
- import { defineCollection, defineGlobal, defineConfig } from '@dyrected/core'
241
- import { SqliteAdapter } from '@dyrected/db-sqlite'
242
-
243
- const media = defineCollection({
244
- slug: 'media',
245
- upload: true,
246
- fields: [
247
- { name: 'alt', label: 'Alt Text', type: 'text' },
248
- ],
249
- })
250
-
251
- const pages = defineCollection({
252
- slug: 'pages',
253
- admin: { icon: 'FileText', useAsTitle: 'title', group: 'Content' },
254
- fields: [
255
- { name: 'title', label: 'Title', type: 'text', required: true },
256
- { name: 'slug', label: 'URL Slug', type: 'text', required: true, unique: true },
257
- { name: 'seo', label: 'SEO Metadata', type: 'object', fields: [
258
- { name: 'metaTitle', label: 'Meta Title', type: 'text' },
259
- { name: 'metaDescription', label: 'Meta Description', type: 'textarea' },
260
- { name: 'ogImage', label: 'OG Image', type: 'relationship', relationTo: 'media' },
261
- ]},
262
- {
263
- name: 'layout',
264
- label: 'Page Layout',
265
- type: 'blocks',
266
- blocks: [
267
- {
268
- slug: 'hero',
269
- labels: { singular: 'Hero', plural: 'Heroes' },
270
- fields: [
271
- { name: 'heading', label: 'Heading', type: 'text', required: true },
272
- { name: 'subheading', label: 'Subheading', type: 'textarea' },
273
- { name: 'image', label: 'Hero Image', type: 'relationship', relationTo: 'media' },
274
- { name: 'ctaLabel', label: 'Button Label', type: 'text' },
275
- { name: 'ctaLink', label: 'Button Link', type: 'url' },
276
- ],
277
- },
278
- {
279
- slug: 'richContent',
280
- labels: { singular: 'Rich Content', plural: 'Rich Content Blocks' },
281
- fields: [
282
- { name: 'content', label: 'Content', type: 'richText', required: true },
283
- ],
284
- },
285
- {
286
- slug: 'callToAction',
287
- labels: { singular: 'Call to Action', plural: 'Calls to Action' },
288
- fields: [
289
- { name: 'heading', label: 'Heading', type: 'text', required: true },
290
- { name: 'description', label: 'Description', type: 'textarea' },
291
- { name: 'buttonLabel', label: 'Button Text', type: 'text' },
292
- { name: 'buttonLink', label: 'Button Link', type: 'url' },
293
- { name: 'theme', label: 'Theme', type: 'select', options: [
294
- { label: 'Primary', value: 'primary' },
295
- { label: 'Secondary', value: 'secondary' },
296
- { label: 'Dark', value: 'dark' },
297
- ]},
298
- ],
299
- },
300
- ],
301
- },
302
- ],
303
- })
304
-
305
- const settings = defineGlobal({
306
- slug: 'settings',
307
- label: 'Site Settings',
308
- fields: [
309
- { name: 'siteName', label: 'Site Name', type: 'text' },
310
- { name: 'tagline', label: 'Site Tagline', type: 'text' },
311
- { name: 'logo', label: 'Site Logo', type: 'relationship', relationTo: 'media' },
312
- { name: 'footerText', label: 'Footer Text', type: 'textarea' },
313
- ],
314
- })
315
-
316
- export default defineConfig({
317
- collections: [media, pages],
318
- globals: [settings],
319
- // Use SqliteAdapter for local, PostgresAdapter or MySqlAdapter for production
320
- db: new SqliteAdapter({ filename: './dyrected.db' }),
321
- })
322
- \`\`\``;
323
- }
324
- function buildDeliverablesSection() {
325
- return `
326
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
327
- 7. REQUIRED DELIVERABLES \u2014 IN THIS ORDER
328
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
329
- Return your response in exactly this order. Do not combine steps. Do not skip steps.
330
-
331
- 1. Diagnostic findings (what exists, what needs to become CMS-managed)
332
- 2. dyrected.config.ts \u2014 complete file
333
- 3. Admin route file \u2014 complete file
334
- 4. Catch-all frontend page route \u2014 complete file
335
- 5. Block components \u2014 names and fields only
336
- 6. Migration/fallback strategy \u2014 numbered steps
337
- 7. Schema sync command
338
-
339
- API Reference: https://docs.dyrected.com
340
- If you are unsure about any syntax or property, refer to the documentation above.`;
341
- }
342
- function buildFrameworkSection(activeTab, isSelfHosted, config) {
343
- const envPrefix = activeTab === "next" ? "NEXT_PUBLIC_" : activeTab === "nuxt" ? "NUXT_PUBLIC_" : "";
344
- const credentialEnvLines = isSelfHosted ? "" : `
345
- apiKey: process.env.${envPrefix}DYRECTED_API_KEY || '${config.apiKey}',
346
- siteId: process.env.${envPrefix}SITE_ID || '${config.siteId}',`;
347
- const baseUrlLine = `process.env.${envPrefix}DYRECTED_URL || '${config.baseUrl || "http://localhost:3000"}'`;
348
- const sections = {
349
- next: `
350
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
351
- 8. IMPLEMENTATION \u2014 Next.js
352
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
353
- 1. SDK Setup (lib/dyrected.ts):
354
- \`\`\`ts
355
- import { createClient } from '@dyrected/sdk'
356
-
357
- export const dyrected = createClient({
358
- baseUrl: ${baseUrlLine},${credentialEnvLines}
359
- })
360
- \`\`\`
361
-
362
- 2. Admin Route (app/cms/page.tsx):
363
- \`\`\`tsx
364
- import { DyrectedAdmin } from '@dyrected/next/admin'
365
-
366
- export default function AdminPage() {
367
- // DyrectedAdmin handles routing, auth, and CSS automatically.
368
- // Do NOT wrap this in custom auth middleware.
369
- return <DyrectedAdmin basename="/cms" />
370
- }
371
- \`\`\`
372
-
373
- 3. Catch-all Page Route (app/[...slug]/page.tsx):
374
- \`\`\`tsx
375
- import { dyrected } from '@/lib/dyrected'
376
-
377
- export default async function CmsPage({ params }: { params: { slug: string[] } }) {
378
- const slug = params.slug?.join('/') || 'home'
379
- const page = await dyrected.collection('pages').findOne({ where: { slug: { equals: slug } } })
380
-
381
- if (!page) return <div>Page not found</div>
382
-
383
- return (
384
- <main>
385
- {page.layout?.map((block: any, i: number) => (
386
- <BlockRenderer key={i} block={block} />
387
- ))}
388
- </main>
389
- )
390
- }
391
- \`\`\``,
392
- nuxt: `
393
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
394
- 8. IMPLEMENTATION \u2014 Nuxt.js
395
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
396
- 1. Nuxt.js Config (nuxt.config.ts):
397
- \`\`\`ts
398
- export default defineNuxtConfig({
399
- modules: ['@dyrected/nuxt'],
400
- dyrected: {
401
- baseUrl: process.env.${envPrefix}DYRECTED_URL || '${config.baseUrl || "http://localhost:3000"}',${isSelfHosted ? "" : `
402
- apiKey: process.env.${envPrefix}DYRECTED_API_KEY || '${config.apiKey}',
403
- siteId: process.env.${envPrefix}SITE_ID || '${config.siteId}',`}
404
- },
405
- })
406
- \`\`\`
407
-
408
- 2. Admin Route (pages/cms/index.vue):
409
- \`\`\`vue
410
- <script setup lang="ts">
411
- // DyrectedAdmin is auto-imported by @dyrected/nuxt.
412
- // Do NOT manually import it.
413
- // Do NOT use renderAdminUI here.
414
- // Do NOT add custom auth middleware \u2014 Dyrected handles authentication.
415
- definePageMeta({ layout: false })
416
- </script>
417
-
418
- <template>
419
- <ClientOnly>
420
- <DyrectedAdmin basename="/cms" />
421
- </ClientOnly>
422
- </template>
423
- \`\`\`
424
-
425
- 3. Catch-all Page Route (pages/[...slug].vue):
426
- \`\`\`vue
427
- <script setup lang="ts">
428
- const route = useRoute()
429
- const slug = computed(() =>
430
- Array.isArray(route.params.slug)
431
- ? route.params.slug.join('/')
432
- : route.params.slug || 'home'
433
- )
434
-
435
- const { data: page } = await useDyrected('pages').findOne({
436
- where: { slug: { equals: slug.value } },
437
- initialData: null,
438
- })
439
- </script>
440
-
441
- <template>
442
- <main v-if="page">
443
- <BlockRenderer
444
- v-for="(block, i) in page.layout"
445
- :key="i"
446
- :block="block"
447
- />
448
- </main>
449
- <div v-else>Page not found</div>
450
- </template>
451
- \`\`\``,
452
- react: `
453
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
454
- 8. IMPLEMENTATION \u2014 React
455
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
456
- 1. SDK Setup (lib/dyrected.ts):
457
- \`\`\`ts
458
- import { createClient } from '@dyrected/sdk'
459
-
460
- export const dyrected = createClient({
461
- baseUrl: '${config.baseUrl || "https://api.dyrected.cloud"}',${isSelfHosted ? "" : `
462
- apiKey: '${config.apiKey}',
463
- siteId: '${config.siteId}',`}
464
- })
465
- \`\`\`
466
-
467
- 2. Admin Route (pages/cms.tsx):
468
- \`\`\`tsx
469
- import { AdminUI } from '@dyrected/admin'
470
- import '@dyrected/admin/styles'
471
-
472
- export default function AdminPage() {
473
- return (
474
- <div style={{ height: '100vh' }}>
475
- <AdminUI
476
- baseUrl="${config.baseUrl || "https://api.dyrected.cloud"}"${isSelfHosted ? "" : `
477
- apiKey="${config.apiKey}"
478
- siteId="${config.siteId}"`}
479
- />
480
- </div>
481
- )
482
- }
483
- \`\`\``,
484
- vue: `
485
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
486
- 8. IMPLEMENTATION \u2014 Vue
487
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
488
- 1. SDK Setup (lib/dyrected.ts):
489
- \`\`\`ts
490
- import { createClient } from '@dyrected/sdk'
491
-
492
- export const dyrected = createClient({
493
- baseUrl: '${config.baseUrl || "https://api.dyrected.cloud"}',${isSelfHosted ? "" : `
494
- apiKey: '${config.apiKey}',
495
- siteId: '${config.siteId}',`}
496
- })
497
- \`\`\`
498
-
499
- 2. Admin Route (pages/cms.vue):
500
- \`\`\`vue
501
- <template>
502
- <div ref="container" style="height: 100vh" />
503
- </template>
504
-
505
- <script setup>
506
- import { ref, onMounted, onUnmounted } from 'vue'
507
- import { renderAdminUI } from '@dyrected/admin'
508
- import '@dyrected/admin/styles'
509
-
510
- const container = ref(null)
511
- let cleanup: (() => void) | undefined
512
-
513
- onMounted(() => {
514
- cleanup = renderAdminUI(container.value, {
515
- baseUrl: '${config.baseUrl || "https://api.dyrected.cloud"}',${isSelfHosted ? "" : `
516
- apiKey: '${config.apiKey}',
517
- siteId: '${config.siteId}',`}
518
- })
519
- })
520
-
521
- onUnmounted(() => cleanup?.())
522
- </script>
523
- \`\`\``
524
- };
525
- return sections[activeTab] || sections.next;
526
- }
527
- function generateFreshSetupPrompt(activeTab, config) {
528
- const frameworkLabel = activeTab === "next" ? "Next.js" : activeTab === "nuxt" ? "Nuxt.js" : activeTab ? activeTab.charAt(0).toUpperCase() + activeTab.slice(1) : "the project's detected framework";
529
- const isSelfHosted = config.isSelfHosted === true || !config.apiKey && !config.siteId;
530
- const frameworkSetup = {
531
- nuxt: `
532
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
533
- 3. INSTALLATION STEPS \u2014 Run these in order
534
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
535
- Tell the user to follow these steps exactly. Explain each one in plain
536
- language before showing the command. Do not skip any step.
537
-
538
- STEP 1 \u2014 Initialize Dyrected
539
- Tell the user:
540
- "Run this command in your terminal inside your Nuxt.js project folder.
541
- It will set everything up for you automatically."
542
-
543
- \`\`\`bash
544
- npx @dyrected/cli init
545
- \`\`\`
546
-
547
- When it asks questions, tell the user to:
548
- - Choose: Nuxt.js 3
549
- - Choose: SQLite (easiest option, no extra setup needed)
550
-
551
- The CLI will automatically:
552
- - Install all required packages
553
- - Create a dyrected.config.ts file
554
- - Mount the Admin UI at pages/cms/index.vue
555
- - Generate a .env.example file
556
-
557
- STEP 2 \u2014 Register the module
558
- Tell the user to open nuxt.config.ts and add '@dyrected/nuxt' to modules:
559
-
560
- \`\`\`ts
561
- export default defineNuxtConfig({
562
- modules: ['@dyrected/nuxt'],
563
- })
564
- \`\`\`
565
-
566
- STEP 3 \u2014 Set up environment variables
567
- Tell the user:
568
- "Find the file called .env.example in your project.
569
- Make a copy of it and rename the copy to .env
570
- Then open .env and fill in the values."
571
-
572
- STEP 4 \u2014 Start the project
573
- \`\`\`bash
574
- pnpm dev
575
- \`\`\`
576
-
577
- STEP 5 \u2014 Open the dashboard
578
- Tell the user to open their browser and go to:
579
- http://localhost:3000/cms
580
-
581
- If they see the Dyrected admin dashboard, the installation worked.
582
- Tell them: "You are ready. Now let's set up your content."`,
583
- next: `
584
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
585
- 3. INSTALLATION STEPS \u2014 Run these in order
586
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
587
- Tell the user to follow these steps exactly. Explain each one in plain
588
- language before showing the command. Do not skip any step.
589
-
590
- STEP 1 \u2014 Initialize Dyrected
591
- Tell the user:
592
- "Run this command in your terminal inside your Next.js project folder."
593
-
594
- \`\`\`bash
595
- npx @dyrected/cli init
596
- \`\`\`
597
-
598
- When it asks questions, tell the user to:
599
- - Choose: Next.js
600
- - Choose: SQLite (easiest option, no extra setup needed)
601
-
602
- The CLI will automatically:
603
- - Install all required packages
604
- - Create a dyrected.config.ts file
605
- - Mount the Admin UI at app/cms/page.tsx
606
- - Generate a .env.example file
607
-
608
- STEP 2 \u2014 Set up environment variables
609
- Tell the user:
610
- "Find the file called .env.example in your project.
611
- Make a copy of it and rename the copy to .env.local
612
- Then open .env.local and fill in the values."
613
-
614
- STEP 3 \u2014 Start the project
615
- \`\`\`bash
616
- pnpm dev
617
- \`\`\`
618
-
619
- STEP 4 \u2014 Open the dashboard
620
- Tell the user to open their browser and go to:
621
- http://localhost:3000/cms
622
-
623
- If they see the Dyrected admin dashboard, the installation worked.
624
- Tell them: "You are ready. Now let's set up your content."`,
625
- react: `
626
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
627
- 3. INSTALLATION STEPS \u2014 Run these in order
628
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
629
- STEP 1 \u2014 Install the SDK and admin packages
630
- \`\`\`bash
631
- npm install @dyrected/sdk @dyrected/admin
632
- \`\`\`
633
-
634
- STEP 2 \u2014 Set up the client (lib/dyrected.ts):
635
- \`\`\`ts
636
- import { createClient } from '@dyrected/sdk'
637
-
638
- export const dyrected = createClient({
639
- baseUrl: '${config.baseUrl || "https://api.dyrected.cloud"}',${isSelfHosted ? "" : `
640
- apiKey: '${config.apiKey}',
641
- siteId: '${config.siteId}',`}
642
- })
643
- \`\`\`
644
-
645
- STEP 3 \u2014 Mount the Admin UI (pages/cms.tsx):
646
- \`\`\`tsx
647
- import { AdminUI } from '@dyrected/admin'
648
- import '@dyrected/admin/styles'
649
-
650
- export default function AdminPage() {
651
- return (
652
- <div style={{ height: '100vh' }}>
653
- <AdminUI
654
- baseUrl="${config.baseUrl || "https://api.dyrected.cloud"}"${isSelfHosted ? "" : `
655
- apiKey="${config.apiKey}"
656
- siteId="${config.siteId}"`}
657
- />
658
- </div>
659
- )
660
- }
661
- \`\`\``,
662
- vue: `
663
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
664
- 3. INSTALLATION STEPS \u2014 Run these in order
665
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
666
- STEP 1 \u2014 Install the SDK and admin packages
667
- \`\`\`bash
668
- npm install @dyrected/sdk @dyrected/admin
669
- \`\`\`
670
-
671
- STEP 2 \u2014 Set up the client (lib/dyrected.ts):
672
- \`\`\`ts
673
- import { createClient } from '@dyrected/sdk'
674
-
675
- export const dyrected = createClient({
676
- baseUrl: '${config.baseUrl || "https://api.dyrected.cloud"}',${isSelfHosted ? "" : `
677
- apiKey: '${config.apiKey}',
678
- siteId: '${config.siteId}',`}
679
- })
680
- \`\`\`
681
-
682
- STEP 3 \u2014 Mount the Admin UI (pages/cms.vue):
683
- \`\`\`vue
684
- <template>
685
- <div ref="container" style="height: 100vh" />
686
- </template>
687
-
688
- <script setup>
689
- import { ref, onMounted, onUnmounted } from 'vue'
690
- import { renderAdminUI } from '@dyrected/admin'
691
- import '@dyrected/admin/styles'
692
-
693
- const container = ref(null)
694
- let cleanup
695
-
696
- onMounted(() => {
697
- cleanup = renderAdminUI(container.value, {
698
- baseUrl: '${config.baseUrl || "https://api.dyrected.cloud"}',${isSelfHosted ? "" : `
699
- apiKey: '${config.apiKey}',
700
- siteId: '${config.siteId}',`}
701
- })
702
- })
703
-
704
- onUnmounted(() => cleanup?.())
705
- </script>
706
- \`\`\``
707
- };
708
- return [
709
- `You are a friendly technical assistant helping someone set up Dyrected CMS for the very first time in a ${frameworkLabel} project. They have NOT installed anything yet.
710
-
711
- Your job is to:
712
- 1. Understand who you are talking to before giving any instructions
713
- 2. Ask simple questions about their project and content needs
714
- 3. Walk them through setup in a way that matches their technical level
715
- 4. Confirm each step worked before moving to the next
716
- 5. Help them design their content so their client can manage it
717
-
718
- Speak in plain language at all times. Never assume technical knowledge.
719
- Never show more than one step at a time.
720
- Never mention the terminal, command line, or any commands until you have
721
- confirmed the user is comfortable running them.`,
722
- `
723
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
724
- 1. ENVIRONMENT
725
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
726
- - Framework : ${frameworkLabel}
727
- - Host Type : ${isSelfHosted ? "Self-Hosted" : "Dyrected Cloud"}
728
- - API Base : ${config.baseUrl || "http://localhost:3000"}
729
- ${isSelfHosted ? "" : `- Site ID : ${config.siteId}
730
- - API Key : ${config.apiKey}`}`.trim(),
731
- `
732
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
733
- 2. PHASE 0 \u2014 UNDERSTAND THE USER FIRST
734
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
735
- Ask these questions one at a time. Wait for each answer before asking the next.
736
- Never ask more than one question at a time.
737
-
738
- \u2500\u2500\u2500 QUESTION 1 \u2014 TECH LEVEL \u2500\u2500\u2500
739
- This is the most important question. Ask it first.
740
-
741
- Ask exactly this:
742
- "Before we dive in \u2014 how would you describe yourself?
743
-
744
- A) I write code myself (I'm comfortable with the terminal and editing files)
745
- B) I use tools like Lovable, Bolt, or v0 to build with AI \u2014 I don't write much code myself
746
- C) I'm a designer or project manager \u2014 someone else usually handles the technical stuff
747
- D) Something else \u2014 just describe how you work"
748
-
749
- Use their answer to decide how to guide them for the rest of the setup:
750
- - If A \u2192 they are TECHNICAL. You may show terminal commands and code directly.
751
- - If B \u2192 they are SEMI-TECHNICAL. Explain what each step does before showing
752
- any code or commands. Ask before running anything in the terminal.
753
- For Lovable users specifically: guide them to use the built-in
754
- terminal or ask them to paste code into the right file in their editor.
755
- - If C \u2192 they are NON-TECHNICAL. Do not show terminal commands at all.
756
- Generate all the code and config for them. Walk them through
757
- copy-pasting into specific files by name. If a terminal step is
758
- unavoidable, warn them first and offer to write the exact command
759
- with a clear explanation of what it does and where to run it.
760
- - If D \u2192 ask one follow-up question to understand their workflow before
761
- deciding which path above fits best.
762
-
763
- \u2500\u2500\u2500 QUESTION 2 \u2014 PROJECT STATUS \u2500\u2500\u2500
764
- Ask after Q1 is answered:
765
-
766
- "Do you already have a ${frameworkLabel} project open,
767
- or are we starting from scratch?
768
-
769
- \u2192 Already have a project: tell me what the site is about or
770
- share the folder name
771
- \u2192 Starting fresh: just say 'new project' and I will help you
772
- create one first"
773
-
774
- \u2500\u2500\u2500 QUESTION 3 \u2014 SITE PURPOSE \u2500\u2500\u2500
775
- Ask after Q2 is answered:
776
-
777
- "What kind of website is this?
778
-
779
- A) A business or agency site (show services, get enquiries)
780
- B) A blog or news site (publish articles regularly)
781
- C) A SaaS or product landing page (features, pricing, sign up)
782
- D) A portfolio (show work and past projects)
783
- E) Something else \u2014 describe it in one sentence"
784
-
785
- \u2500\u2500\u2500 QUESTION 4 \u2014 CONTENT NEEDS \u2500\u2500\u2500
786
- Ask after Q3 is answered:
787
-
788
- "What will your client need to update themselves \u2014 without calling you?
789
-
790
- Some examples to help you think:
791
- \u2192 Blog posts or news articles
792
- \u2192 Team member profiles (name, photo, bio)
793
- \u2192 Services or product descriptions
794
- \u2192 Homepage text (headline, buttons, images)
795
- \u2192 Testimonials or reviews
796
- \u2192 FAQs
797
- \u2192 Event listings or announcements
798
-
799
- Just list the ones that apply. Or say 'not sure yet'
800
- and we will figure it out together."
801
-
802
- \u2500\u2500\u2500 AFTER ALL QUESTIONS \u2500\u2500\u2500
803
- Once all four questions are answered:
804
- 1. Summarise what you understood in plain English
805
- 2. Ask the user to confirm before touching any code
806
- 3. Then move to the installation steps using the correct path
807
- for their tech level from Question 1`,
808
- frameworkSetup[activeTab] || frameworkSetup.nuxt,
809
- `
810
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
811
- 4. AFTER INSTALLATION \u2014 CONTENT SETUP
812
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
813
- Once the dashboard is confirmed working, help the user design their
814
- content model in dyrected.config.ts based on what they told you in
815
- Phase 0.
816
-
817
- RULES for this phase:
818
- - Use defineCollection and defineConfig from '@dyrected/core'
819
- - Use client.collection(slug) only \u2014 never client.collections
820
- - Always use initialData in all data fetches
821
- - Use a catch-all pages collection for marketing-managed pages
822
- - Use blocks for flexible page layouts
823
- - Use type: 'relationship', relationTo: 'collectionSlug' for relations
824
- - Never throw during render \u2014 fall back to initialData on errors
825
- - All relationship fields must handle null gracefully
826
-
827
- CONTENT SETUP DELIVERABLES \u2014 in this order:
828
- 1. dyrected.config.ts \u2014 complete file based on their answers
829
- 2. Catch-all page route for CMS-managed pages
830
- 3. Block components list (names and fields only)
831
- 4. One example fetch showing how to load content on a page
832
-
833
- After delivering the config, tell the user to sync their schema.
834
- For TECHNICAL users show the command directly:
835
- \`\`\`bash
836
- npx @dyrected/cli sync:schema
837
- \`\`\`
838
- For SEMI-TECHNICAL or NON-TECHNICAL users explain it first:
839
- "This next step tells Dyrected to read your content setup file
840
- and prepare the database. Here is the command to run:" then show it.
841
-
842
- Then ask: "Do you want me to help you connect this content to your
843
- frontend pages now?"
844
-
845
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
846
- DO NOT
847
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
848
- - Do NOT show terminal commands before confirming the user's tech level
849
- - Do NOT assume the user knows what a terminal, CLI, or package manager is
850
- - Do NOT show all steps at once \u2014 one step at a time always
851
- - Do NOT use client.collections \u2014 use client.collection(slug)
852
- - Do NOT add custom auth middleware to the admin route
853
- - Do NOT use renderAdminUI in a Nuxt.js project
854
- - Do NOT skip the confirmation after each installation step
855
- - Do NOT use jargon without explaining it in plain English first
856
- - Do NOT assume the installation worked \u2014 ask the user to confirm
857
-
858
- API Reference: https://docs.dyrected.com
859
- If you are unsure about any syntax, refer to the official documentation above.`
860
- ].join("\n");
861
- }
862
- function generateAIPrompt(activeTab, config) {
863
- const frameworkLabel = activeTab === "next" ? "Next.js" : activeTab === "nuxt" ? "Nuxt.js" : activeTab ? activeTab.charAt(0).toUpperCase() + activeTab.slice(1) : "the project's detected framework";
864
- const isSelfHosted = config.isSelfHosted === true || !config.apiKey && !config.siteId;
865
- const existingSite = config.existingSite ?? false;
866
- const missionText = existingSite ? `You are a Senior Content Architect. Your mission is to integrate Dyrected CMS into an EXISTING ${frameworkLabel} project. Your absolute priority is DATA PRESERVATION and MIGRATION of existing hardcoded content into a flexible, blocks-based schema that empowers marketing teams to move independently.` : `You are a Senior Content Architect. Your mission is to integrate Dyrected CMS into a NEW ${frameworkLabel} project. Your priority is DATA PRESERVATION and creating a CMS that empowers marketing teams to move independently without raising tickets to engineering.`;
867
- const sections = [
868
- missionText,
869
- buildEnvironmentSection(frameworkLabel, isSelfHosted, config),
870
- buildDiagnosticSection(),
871
- buildConstraintsSection(),
872
- buildSchemaRulesSection(),
873
- buildDoNotSection(),
874
- buildTechnicalReferenceSection(),
875
- buildDeliverablesSection(),
876
- buildFrameworkSection(activeTab, isSelfHosted, config)
877
- ];
878
- return sections.join("\n");
879
- }
880
-
881
60
  // src/workflows.ts
882
61
  var WORKFLOW_HISTORY_COLLECTION = "__workflow_history";
883
62
  var LIFECYCLE_EVENTS_COLLECTION = "__lifecycle_events";
@@ -915,6 +94,14 @@ function workflowCapabilities(workflow, user) {
915
94
  }
916
95
  return capabilities;
917
96
  }
97
+ function canViewWorkflowDraft(workflow, user) {
98
+ if (!user) return false;
99
+ const capabilities = workflowCapabilities(workflow, user);
100
+ if (capabilities.has("entry.edit")) return true;
101
+ return workflow.transitions.some(
102
+ (transition) => (transition.requiredCapabilities ?? []).some((capability) => capabilities.has(capability))
103
+ );
104
+ }
918
105
  function availableWorkflowTransitions(workflow, state, user) {
919
106
  const capabilities = workflowCapabilities(workflow, user);
920
107
  return workflow.transitions.filter((transition) => {
@@ -932,7 +119,7 @@ function materializeWorkflowDocument(doc, workflow, user) {
932
119
  const meta = doc.__workflow;
933
120
  if (!meta) return doc;
934
121
  const { __published, __workflow, ...working } = doc;
935
- if (!user) {
122
+ if (!canViewWorkflowDraft(workflow, user)) {
936
123
  if (!__published || typeof __published !== "object") return null;
937
124
  return { id: doc.id, ...__published, _workflow: publicMetadata(meta) };
938
125
  }
@@ -993,6 +180,7 @@ async function dispatchLifecycleEvent(config, event) {
993
180
  }
994
181
  async function dispatchPendingLifecycleEvents(config, limit = 50) {
995
182
  if (!config.db || !config.events?.handlers.length) return 0;
183
+ const maxAttempts = config.events.maxAttempts ?? 8;
996
184
  const result = await config.db.find({
997
185
  collection: LIFECYCLE_EVENTS_COLLECTION,
998
186
  where: { status: { in: ["pending", "failed"] } },
@@ -1000,7 +188,9 @@ async function dispatchPendingLifecycleEvents(config, limit = 50) {
1000
188
  limit
1001
189
  });
1002
190
  const now = Date.now();
1003
- const due = result.docs.filter((doc) => !doc.nextAttemptAt || new Date(doc.nextAttemptAt).getTime() <= now);
191
+ const due = result.docs.filter(
192
+ (doc) => Number(doc.attempts ?? 0) < maxAttempts && (!doc.nextAttemptAt || new Date(doc.nextAttemptAt).getTime() <= now)
193
+ );
1004
194
  for (const event of due) await dispatchLifecycleEvent(config, event);
1005
195
  return due.length;
1006
196
  }
@@ -1089,12 +279,18 @@ async function transitionWorkflow(args) {
1089
279
  events.push(createLifecycleEvent({ name: "entry.unpublished", collection: collection.slug, documentId: id, actorId: user?.sub, payload: { revision: meta.publishedRevision } }));
1090
280
  }
1091
281
  const updated = await db.transaction(async (tx) => {
282
+ const locked = await tx.findOne({ collection: collection.slug, id });
283
+ if (!locked) throw Object.assign(new Error("Not Found"), { statusCode: 404 });
284
+ const lockedMeta = locked.__workflow;
285
+ if (lockedMeta.revision !== meta.revision || lockedMeta.state !== meta.state || expectedRevision !== void 0 && lockedMeta.revision !== expectedRevision) {
286
+ throw Object.assign(new Error("This entry changed since it was loaded. Refresh before transitioning it."), { statusCode: 409 });
287
+ }
1092
288
  const now = (/* @__PURE__ */ new Date()).toISOString();
1093
- const { __published: _published, __workflow: _workflow, id: _id, ...working } = original;
289
+ const { __published: _published, __workflow: _workflow, id: _id, ...working } = locked;
1094
290
  const nextMeta = {
1095
- ...meta,
291
+ ...lockedMeta,
1096
292
  state: transition.to,
1097
- ...targetState.published ? { publishedRevision: meta.revision, publishedAt: now, publishedBy: user?.sub } : {},
293
+ ...targetState.published ? { publishedRevision: lockedMeta.revision, publishedAt: now, publishedBy: user?.sub } : {},
1098
294
  ...transition.unpublish ? { publishedRevision: void 0, publishedAt: void 0, publishedBy: void 0 } : {}
1099
295
  };
1100
296
  const data = { __workflow: nextMeta };
@@ -1103,11 +299,11 @@ async function transitionWorkflow(args) {
1103
299
  const next = await tx.update({ collection: collection.slug, id, data });
1104
300
  await tx.create({
1105
301
  collection: WORKFLOW_HISTORY_COLLECTION,
1106
- 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 }
302
+ 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 }
1107
303
  });
1108
304
  for (const event of events) await persistEvent(tx, event);
1109
305
  if (collection.audit) {
1110
- 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 }) } });
306
+ 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 }) } });
1111
307
  }
1112
308
  return next;
1113
309
  });
@@ -1647,6 +843,831 @@ async function executeFieldAfterRead(fields, doc, user, db) {
1647
843
  return result;
1648
844
  }
1649
845
 
846
+ // src/utils/openapi.ts
847
+ function generateOpenApi(config) {
848
+ const spec = {
849
+ openapi: "3.0.0",
850
+ info: {
851
+ title: "Dyrected API",
852
+ version: "1.0.0",
853
+ description: "Automatically generated OpenAPI specification for the Dyrected project."
854
+ },
855
+ components: {
856
+ schemas: {
857
+ WorkflowMetadata: {
858
+ type: "object",
859
+ required: ["state", "revision"],
860
+ properties: {
861
+ state: { type: "string" },
862
+ revision: { type: "integer", minimum: 1 },
863
+ publishedRevision: { type: "integer", minimum: 1 },
864
+ publishedAt: { type: "string", format: "date-time" },
865
+ publishedBy: { type: "string" },
866
+ availableTransitions: { type: "array", items: { type: "string" } }
867
+ }
868
+ },
869
+ WorkflowTransitionRequest: {
870
+ type: "object",
871
+ properties: {
872
+ expectedRevision: { type: "integer", minimum: 1 },
873
+ comment: { type: "string" }
874
+ }
875
+ },
876
+ WorkflowHistoryEntry: {
877
+ type: "object",
878
+ required: [
879
+ "collection",
880
+ "documentId",
881
+ "transition",
882
+ "from",
883
+ "to",
884
+ "revision",
885
+ "createdAt"
886
+ ],
887
+ properties: {
888
+ id: { type: "string" },
889
+ collection: { type: "string" },
890
+ documentId: { type: "string" },
891
+ transition: { type: "string" },
892
+ from: { type: "string" },
893
+ to: { type: "string" },
894
+ revision: { type: "integer" },
895
+ comment: { type: "string", nullable: true },
896
+ actorId: { type: "string", nullable: true },
897
+ createdAt: { type: "string", format: "date-time" }
898
+ }
899
+ },
900
+ Error: {
901
+ type: "object",
902
+ properties: {
903
+ message: { type: "string" },
904
+ errors: {
905
+ type: "array",
906
+ items: { type: "object", additionalProperties: true }
907
+ }
908
+ }
909
+ },
910
+ AuthCredentials: {
911
+ type: "object",
912
+ required: ["email", "password"],
913
+ properties: {
914
+ email: { type: "string", format: "email" },
915
+ password: { type: "string" }
916
+ }
917
+ },
918
+ TokenResponse: {
919
+ type: "object",
920
+ properties: {
921
+ token: { type: "string" },
922
+ user: { type: "object", additionalProperties: true }
923
+ }
924
+ }
925
+ },
926
+ securitySchemes: {
927
+ ApiKeyAuth: {
928
+ type: "apiKey",
929
+ in: "header",
930
+ name: "x-api-key"
931
+ }
932
+ }
933
+ },
934
+ paths: {},
935
+ security: [{ ApiKeyAuth: [] }]
936
+ };
937
+ spec.paths["/api/schemas"] = {
938
+ get: {
939
+ tags: ["System"],
940
+ summary: "Get the serialized Dyrected schema",
941
+ security: [],
942
+ responses: {
943
+ 200: { description: "Collection and global schema definitions" }
944
+ }
945
+ }
946
+ };
947
+ spec.paths["/api/openapi.json"] = {
948
+ get: {
949
+ tags: ["System"],
950
+ summary: "Get the OpenAPI specification",
951
+ security: [],
952
+ responses: { 200: { description: "OpenAPI 3.0 document" } }
953
+ }
954
+ };
955
+ spec.paths["/api/docs"] = {
956
+ get: {
957
+ tags: ["System"],
958
+ summary: "Open interactive API documentation",
959
+ security: [],
960
+ responses: { 200: { description: "Swagger UI HTML" } }
961
+ }
962
+ };
963
+ spec.paths["/api/dyrected/options/{collection}/{field}"] = {
964
+ get: {
965
+ tags: ["System"],
966
+ summary: "Resolve dynamic field options",
967
+ parameters: [
968
+ {
969
+ name: "collection",
970
+ in: "path",
971
+ required: true,
972
+ schema: { type: "string" }
973
+ },
974
+ {
975
+ name: "field",
976
+ in: "path",
977
+ required: true,
978
+ schema: { type: "string" }
979
+ }
980
+ ],
981
+ responses: { 200: { description: "Resolved option items" } }
982
+ }
983
+ };
984
+ spec.paths["/api/preferences/{key}"] = {
985
+ get: {
986
+ tags: ["Preferences"],
987
+ summary: "Get an authenticated user preference",
988
+ parameters: [
989
+ { name: "key", in: "path", required: true, schema: { type: "string" } }
990
+ ],
991
+ responses: { 200: { description: "Preference value" } }
992
+ },
993
+ put: {
994
+ tags: ["Preferences"],
995
+ summary: "Set an authenticated user preference",
996
+ parameters: [
997
+ { name: "key", in: "path", required: true, schema: { type: "string" } }
998
+ ],
999
+ requestBody: {
1000
+ required: true,
1001
+ content: { "application/json": { schema: { type: "object" } } }
1002
+ },
1003
+ responses: { 200: { description: "Updated preference" } }
1004
+ }
1005
+ };
1006
+ spec.paths["/api/preview-token"] = {
1007
+ post: {
1008
+ tags: ["Preview"],
1009
+ summary: "Create a preview token",
1010
+ responses: { 200: { description: "Short-lived preview token" } }
1011
+ }
1012
+ };
1013
+ spec.paths["/api/preview-data"] = {
1014
+ get: {
1015
+ tags: ["Preview"],
1016
+ summary: "Resolve preview data from a token",
1017
+ security: [],
1018
+ parameters: [
1019
+ {
1020
+ name: "token",
1021
+ in: "query",
1022
+ required: true,
1023
+ schema: { type: "string" }
1024
+ }
1025
+ ],
1026
+ responses: { 200: { description: "Preview document data" } }
1027
+ }
1028
+ };
1029
+ for (const collection of config.collections) {
1030
+ spec.components.schemas[collection.slug] = collectionToSchema(collection);
1031
+ }
1032
+ for (const global of config.globals) {
1033
+ spec.components.schemas[global.slug] = globalToSchema(global);
1034
+ }
1035
+ for (const collection of config.collections) {
1036
+ const slug = collection.slug;
1037
+ const path = `/api/collections/${slug}`;
1038
+ const labels = collection.labels || { singular: slug, plural: `${slug}s` };
1039
+ spec.paths[path] = {
1040
+ get: {
1041
+ tags: ["Collections"],
1042
+ summary: `Find ${labels.plural}`,
1043
+ parameters: [
1044
+ {
1045
+ name: "limit",
1046
+ in: "query",
1047
+ schema: { type: "integer", default: 10 }
1048
+ },
1049
+ {
1050
+ name: "page",
1051
+ in: "query",
1052
+ schema: { type: "integer", default: 1 }
1053
+ },
1054
+ {
1055
+ name: "where",
1056
+ in: "query",
1057
+ schema: { type: "string" },
1058
+ description: "JSON filter"
1059
+ },
1060
+ {
1061
+ name: "sort",
1062
+ in: "query",
1063
+ schema: { type: "string" },
1064
+ description: "Sort field (e.g. -createdAt)"
1065
+ }
1066
+ ],
1067
+ responses: {
1068
+ 200: {
1069
+ description: "Success",
1070
+ content: {
1071
+ "application/json": {
1072
+ schema: {
1073
+ type: "object",
1074
+ properties: {
1075
+ docs: {
1076
+ type: "array",
1077
+ items: { $ref: `#/components/schemas/${slug}` }
1078
+ },
1079
+ total: { type: "integer" },
1080
+ limit: { type: "integer" },
1081
+ page: { type: "integer" }
1082
+ }
1083
+ }
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ },
1089
+ post: {
1090
+ tags: ["Collections"],
1091
+ summary: `Create ${labels.singular}`,
1092
+ requestBody: {
1093
+ required: true,
1094
+ content: {
1095
+ "application/json": {
1096
+ schema: { $ref: `#/components/schemas/${slug}` }
1097
+ }
1098
+ }
1099
+ },
1100
+ responses: {
1101
+ 201: {
1102
+ description: "Created",
1103
+ content: {
1104
+ "application/json": {
1105
+ schema: { $ref: `#/components/schemas/${slug}` }
1106
+ }
1107
+ }
1108
+ }
1109
+ }
1110
+ }
1111
+ };
1112
+ spec.paths[`${path}/{id}`] = {
1113
+ get: {
1114
+ tags: ["Collections"],
1115
+ summary: `Get a single ${labels.singular}`,
1116
+ parameters: [
1117
+ {
1118
+ name: "id",
1119
+ in: "path",
1120
+ required: true,
1121
+ schema: { type: "string" }
1122
+ }
1123
+ ],
1124
+ responses: {
1125
+ 200: {
1126
+ description: "Success",
1127
+ content: {
1128
+ "application/json": {
1129
+ schema: { $ref: `#/components/schemas/${slug}` }
1130
+ }
1131
+ }
1132
+ }
1133
+ }
1134
+ },
1135
+ patch: {
1136
+ tags: ["Collections"],
1137
+ summary: `Update ${labels.singular}`,
1138
+ parameters: [
1139
+ {
1140
+ name: "id",
1141
+ in: "path",
1142
+ required: true,
1143
+ schema: { type: "string" }
1144
+ }
1145
+ ],
1146
+ requestBody: {
1147
+ required: true,
1148
+ content: {
1149
+ "application/json": {
1150
+ schema: { $ref: `#/components/schemas/${slug}` }
1151
+ }
1152
+ }
1153
+ },
1154
+ responses: {
1155
+ 200: {
1156
+ description: "Updated",
1157
+ content: {
1158
+ "application/json": {
1159
+ schema: { $ref: `#/components/schemas/${slug}` }
1160
+ }
1161
+ }
1162
+ }
1163
+ }
1164
+ },
1165
+ delete: {
1166
+ tags: ["Collections"],
1167
+ summary: `Delete ${labels.singular}`,
1168
+ parameters: [
1169
+ {
1170
+ name: "id",
1171
+ in: "path",
1172
+ required: true,
1173
+ schema: { type: "string" }
1174
+ }
1175
+ ],
1176
+ responses: {
1177
+ 204: { description: "Deleted" }
1178
+ }
1179
+ }
1180
+ };
1181
+ spec.paths[`${path}/delete-many`] = {
1182
+ delete: {
1183
+ tags: ["Collections"],
1184
+ summary: `Delete multiple ${labels.plural}`,
1185
+ requestBody: {
1186
+ required: true,
1187
+ content: {
1188
+ "application/json": {
1189
+ schema: {
1190
+ type: "object",
1191
+ required: ["ids"],
1192
+ properties: {
1193
+ ids: { type: "array", items: { type: "string" } }
1194
+ }
1195
+ }
1196
+ }
1197
+ }
1198
+ },
1199
+ responses: { 200: { description: "Deleted and failed document IDs" } }
1200
+ }
1201
+ };
1202
+ spec.paths[`${path}/seed`] = {
1203
+ post: {
1204
+ tags: ["Collections"],
1205
+ summary: `Seed initial ${labels.plural}`,
1206
+ responses: { 200: { description: "Seeded documents" } }
1207
+ }
1208
+ };
1209
+ if (collection.upload) {
1210
+ spec.paths[`${path}/media`] = {
1211
+ get: {
1212
+ tags: ["Media"],
1213
+ summary: `List ${labels.plural}`,
1214
+ responses: { 200: { description: "Paginated media documents" } }
1215
+ },
1216
+ post: {
1217
+ tags: ["Media"],
1218
+ summary: `Upload ${labels.singular}`,
1219
+ requestBody: {
1220
+ required: true,
1221
+ content: {
1222
+ "multipart/form-data": {
1223
+ schema: {
1224
+ type: "object",
1225
+ required: ["file"],
1226
+ properties: { file: { type: "string", format: "binary" } }
1227
+ }
1228
+ }
1229
+ }
1230
+ },
1231
+ responses: { 201: { description: "Uploaded media document" } }
1232
+ }
1233
+ };
1234
+ spec.paths[`${path}/media/{filename}`] = {
1235
+ get: {
1236
+ tags: ["Media"],
1237
+ summary: `Serve ${labels.singular} bytes`,
1238
+ parameters: [
1239
+ {
1240
+ name: "filename",
1241
+ in: "path",
1242
+ required: true,
1243
+ schema: { type: "string" }
1244
+ }
1245
+ ],
1246
+ security: [],
1247
+ responses: {
1248
+ 200: { description: "Stored file" },
1249
+ 404: { description: "File not found" }
1250
+ }
1251
+ }
1252
+ };
1253
+ }
1254
+ if (collection.auth) {
1255
+ const publicAuthPost = (summary) => ({
1256
+ tags: ["Authentication"],
1257
+ summary,
1258
+ security: [],
1259
+ requestBody: {
1260
+ required: true,
1261
+ content: {
1262
+ "application/json": {
1263
+ schema: { type: "object", additionalProperties: true }
1264
+ }
1265
+ }
1266
+ },
1267
+ responses: {
1268
+ 200: { description: "Success" },
1269
+ 400: { description: "Invalid request" }
1270
+ }
1271
+ });
1272
+ spec.paths[`${path}/login`] = {
1273
+ post: {
1274
+ ...publicAuthPost(`Log in to ${labels.plural}`),
1275
+ requestBody: {
1276
+ required: true,
1277
+ content: {
1278
+ "application/json": {
1279
+ schema: { $ref: "#/components/schemas/AuthCredentials" }
1280
+ }
1281
+ }
1282
+ },
1283
+ responses: {
1284
+ 200: {
1285
+ description: "Authenticated",
1286
+ content: {
1287
+ "application/json": {
1288
+ schema: { $ref: "#/components/schemas/TokenResponse" }
1289
+ }
1290
+ }
1291
+ },
1292
+ 401: { description: "Invalid credentials" }
1293
+ }
1294
+ }
1295
+ };
1296
+ spec.paths[`${path}/logout`] = {
1297
+ post: {
1298
+ tags: ["Authentication"],
1299
+ summary: `Log out of ${labels.plural}`,
1300
+ responses: { 200: { description: "Logged out" } }
1301
+ }
1302
+ };
1303
+ spec.paths[`${path}/init`] = {
1304
+ get: {
1305
+ tags: ["Authentication"],
1306
+ summary: `Get ${labels.plural} initialization state`,
1307
+ security: [],
1308
+ responses: { 200: { description: "Initialization state" } }
1309
+ }
1310
+ };
1311
+ spec.paths[`${path}/first-user`] = {
1312
+ post: publicAuthPost(`Register the first ${labels.singular}`)
1313
+ };
1314
+ spec.paths[`${path}/me`] = {
1315
+ get: {
1316
+ tags: ["Authentication"],
1317
+ summary: `Get the current ${labels.singular}`,
1318
+ responses: { 200: { description: "Authenticated user" } }
1319
+ }
1320
+ };
1321
+ spec.paths[`${path}/refresh-token`] = {
1322
+ post: {
1323
+ tags: ["Authentication"],
1324
+ summary: "Refresh an authentication token",
1325
+ responses: { 200: { description: "Refreshed token" } }
1326
+ }
1327
+ };
1328
+ spec.paths[`${path}/forgot-password`] = {
1329
+ post: publicAuthPost("Request a password reset")
1330
+ };
1331
+ spec.paths[`${path}/reset-password`] = {
1332
+ post: publicAuthPost("Reset a password")
1333
+ };
1334
+ spec.paths[`${path}/invite`] = {
1335
+ post: {
1336
+ tags: ["Authentication"],
1337
+ summary: `Invite a ${labels.singular}`,
1338
+ responses: { 200: { description: "Invitation sent" } }
1339
+ }
1340
+ };
1341
+ spec.paths[`${path}/accept-invite`] = {
1342
+ post: publicAuthPost("Accept an invitation")
1343
+ };
1344
+ spec.paths[`${path}/{id}/change-password`] = {
1345
+ post: {
1346
+ tags: ["Authentication"],
1347
+ summary: `Change a ${labels.singular} password`,
1348
+ parameters: [
1349
+ {
1350
+ name: "id",
1351
+ in: "path",
1352
+ required: true,
1353
+ schema: { type: "string" }
1354
+ }
1355
+ ],
1356
+ responses: { 200: { description: "Password changed" } }
1357
+ }
1358
+ };
1359
+ }
1360
+ if (collection.workflow) {
1361
+ spec.paths[`${path}/{id}/transitions/{transition}`] = {
1362
+ post: {
1363
+ tags: ["Workflows"],
1364
+ summary: `Transition ${labels.singular} workflow`,
1365
+ parameters: [
1366
+ {
1367
+ name: "id",
1368
+ in: "path",
1369
+ required: true,
1370
+ schema: { type: "string" }
1371
+ },
1372
+ {
1373
+ name: "transition",
1374
+ in: "path",
1375
+ required: true,
1376
+ schema: {
1377
+ type: "string",
1378
+ enum: collection.workflow.transitions.map((item) => item.name)
1379
+ }
1380
+ }
1381
+ ],
1382
+ requestBody: {
1383
+ content: {
1384
+ "application/json": {
1385
+ schema: {
1386
+ $ref: "#/components/schemas/WorkflowTransitionRequest"
1387
+ }
1388
+ }
1389
+ }
1390
+ },
1391
+ responses: {
1392
+ 200: {
1393
+ description: "Transition applied",
1394
+ content: {
1395
+ "application/json": {
1396
+ schema: { $ref: `#/components/schemas/${slug}` }
1397
+ }
1398
+ }
1399
+ },
1400
+ 400: { description: "Required transition input is missing" },
1401
+ 403: { description: "Transition capability denied" },
1402
+ 409: { description: "Invalid state or stale revision" }
1403
+ }
1404
+ }
1405
+ };
1406
+ spec.paths[`${path}/{id}/workflow-history`] = {
1407
+ get: {
1408
+ tags: ["Workflows"],
1409
+ summary: `Get ${labels.singular} workflow history`,
1410
+ parameters: [
1411
+ {
1412
+ name: "id",
1413
+ in: "path",
1414
+ required: true,
1415
+ schema: { type: "string" }
1416
+ },
1417
+ {
1418
+ name: "limit",
1419
+ in: "query",
1420
+ schema: { type: "integer", default: 50, maximum: 100 }
1421
+ }
1422
+ ],
1423
+ responses: {
1424
+ 200: {
1425
+ description: "Workflow transition history",
1426
+ content: {
1427
+ "application/json": {
1428
+ schema: {
1429
+ type: "object",
1430
+ properties: {
1431
+ docs: {
1432
+ type: "array",
1433
+ items: {
1434
+ $ref: "#/components/schemas/WorkflowHistoryEntry"
1435
+ }
1436
+ },
1437
+ total: { type: "integer" },
1438
+ limit: { type: "integer" },
1439
+ page: { type: "integer" }
1440
+ }
1441
+ }
1442
+ }
1443
+ }
1444
+ },
1445
+ 403: { description: "Collection read access denied" }
1446
+ }
1447
+ }
1448
+ };
1449
+ }
1450
+ }
1451
+ for (const global of config.globals) {
1452
+ const slug = global.slug;
1453
+ const path = `/api/globals/${slug}`;
1454
+ spec.paths[path] = {
1455
+ get: {
1456
+ tags: ["Globals"],
1457
+ summary: `Get ${global.label || slug}`,
1458
+ responses: {
1459
+ 200: {
1460
+ description: "Success",
1461
+ content: {
1462
+ "application/json": {
1463
+ schema: { $ref: `#/components/schemas/${slug}` }
1464
+ }
1465
+ }
1466
+ }
1467
+ }
1468
+ },
1469
+ patch: {
1470
+ tags: ["Globals"],
1471
+ summary: `Update ${global.label || slug}`,
1472
+ requestBody: {
1473
+ required: true,
1474
+ content: {
1475
+ "application/json": {
1476
+ schema: { $ref: `#/components/schemas/${slug}` }
1477
+ }
1478
+ }
1479
+ },
1480
+ responses: {
1481
+ 200: {
1482
+ description: "Updated",
1483
+ content: {
1484
+ "application/json": {
1485
+ schema: { $ref: `#/components/schemas/${slug}` }
1486
+ }
1487
+ }
1488
+ }
1489
+ }
1490
+ }
1491
+ };
1492
+ spec.paths[`${path}/seed`] = {
1493
+ post: {
1494
+ tags: ["Globals"],
1495
+ summary: `Seed ${global.label || slug}`,
1496
+ responses: { 200: { description: "Seeded global" } }
1497
+ }
1498
+ };
1499
+ }
1500
+ if (config.storage) {
1501
+ spec.paths["/api/media/{filename}"] = {
1502
+ get: {
1503
+ tags: ["Media"],
1504
+ summary: "Serve a stored file",
1505
+ security: [],
1506
+ parameters: [
1507
+ {
1508
+ name: "filename",
1509
+ in: "path",
1510
+ required: true,
1511
+ schema: { type: "string" }
1512
+ }
1513
+ ],
1514
+ responses: {
1515
+ 200: { description: "Stored file bytes" },
1516
+ 404: { description: "File not found" }
1517
+ }
1518
+ }
1519
+ };
1520
+ }
1521
+ return spec;
1522
+ }
1523
+ function collectionToSchema(collection) {
1524
+ const { properties, required } = fieldsToProperties(collection.fields);
1525
+ return {
1526
+ type: "object",
1527
+ properties: {
1528
+ id: { type: "string" },
1529
+ createdAt: { type: "string", format: "date-time" },
1530
+ updatedAt: { type: "string", format: "date-time" },
1531
+ ...collection.workflow ? { _workflow: { $ref: "#/components/schemas/WorkflowMetadata" } } : {},
1532
+ ...properties
1533
+ },
1534
+ required: ["id", ...required]
1535
+ };
1536
+ }
1537
+ function globalToSchema(global) {
1538
+ const { properties, required } = fieldsToProperties(global.fields);
1539
+ return {
1540
+ type: "object",
1541
+ properties,
1542
+ required
1543
+ };
1544
+ }
1545
+ function fieldsToProperties(fields) {
1546
+ const props = {};
1547
+ const required = [];
1548
+ for (const field of fields) {
1549
+ if (field.type === "row") {
1550
+ const nested = fieldsToProperties(field.fields || []);
1551
+ Object.assign(props, nested.properties);
1552
+ required.push(...nested.required);
1553
+ continue;
1554
+ }
1555
+ if (!field.name) continue;
1556
+ props[field.name] = fieldToSchema(field);
1557
+ if (field.required) {
1558
+ required.push(field.name);
1559
+ }
1560
+ }
1561
+ return { properties: props, required };
1562
+ }
1563
+ function fieldToSchema(field) {
1564
+ let schema = {};
1565
+ switch (field.type) {
1566
+ case "text":
1567
+ case "textarea":
1568
+ case "email":
1569
+ schema = { type: "string" };
1570
+ break;
1571
+ case "url":
1572
+ schema = {
1573
+ oneOf: [
1574
+ { type: "string" },
1575
+ { type: "object", additionalProperties: true }
1576
+ ]
1577
+ };
1578
+ break;
1579
+ case "icon":
1580
+ schema = { type: "string" };
1581
+ break;
1582
+ case "number":
1583
+ schema = { type: "number" };
1584
+ break;
1585
+ case "boolean":
1586
+ schema = { type: "boolean" };
1587
+ break;
1588
+ case "date":
1589
+ schema = { type: "string", format: "date" };
1590
+ break;
1591
+ case "datetime":
1592
+ schema = { type: "string", format: "date-time" };
1593
+ break;
1594
+ case "time":
1595
+ schema = { type: "string", format: "time" };
1596
+ break;
1597
+ case "select":
1598
+ case "radio":
1599
+ schema = {
1600
+ type: "string",
1601
+ enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0
1602
+ };
1603
+ break;
1604
+ case "multiSelect":
1605
+ schema = {
1606
+ type: "array",
1607
+ items: {
1608
+ type: "string",
1609
+ enum: Array.isArray(field.options) ? field.options.map((o) => typeof o === "string" ? o : o.value) : void 0
1610
+ }
1611
+ };
1612
+ break;
1613
+ case "relationship":
1614
+ case "image": {
1615
+ const valueSchema = {
1616
+ type: "string",
1617
+ description: `ID of a ${field.relationTo} record`
1618
+ };
1619
+ schema = field.hasMany ? { type: "array", items: valueSchema } : valueSchema;
1620
+ break;
1621
+ }
1622
+ case "join":
1623
+ schema = {
1624
+ type: "array",
1625
+ readOnly: true,
1626
+ items: { type: "object", additionalProperties: true }
1627
+ };
1628
+ break;
1629
+ case "object": {
1630
+ const { properties, required } = fieldsToProperties(field.fields || []);
1631
+ schema = { type: "object", properties, required };
1632
+ break;
1633
+ }
1634
+ case "array": {
1635
+ const { properties, required } = fieldsToProperties(field.fields || []);
1636
+ schema = {
1637
+ type: "array",
1638
+ items: { type: "object", properties, required }
1639
+ };
1640
+ break;
1641
+ }
1642
+ case "json":
1643
+ case "richText":
1644
+ schema = { type: "object", additionalProperties: true };
1645
+ break;
1646
+ case "blocks":
1647
+ schema = {
1648
+ type: "array",
1649
+ items: {
1650
+ oneOf: field.blocks?.map((block) => {
1651
+ const { properties, required } = fieldsToProperties(block.fields);
1652
+ return {
1653
+ type: "object",
1654
+ properties: {
1655
+ blockType: { type: "string", enum: [block.slug] },
1656
+ ...properties
1657
+ },
1658
+ required: ["blockType", ...required]
1659
+ };
1660
+ })
1661
+ }
1662
+ };
1663
+ break;
1664
+ default:
1665
+ schema = { type: "string" };
1666
+ }
1667
+ if (field.label) schema.description = field.label;
1668
+ return schema;
1669
+ }
1670
+
1650
1671
  // src/index.ts
1651
1672
  function defineCollection(config) {
1652
1673
  return config;
@@ -1663,6 +1684,7 @@ function defineConfig(config) {
1663
1684
  LIFECYCLE_EVENT_NAMES,
1664
1685
  WORKFLOW_HISTORY_COLLECTION,
1665
1686
  availableWorkflowTransitions,
1687
+ canViewWorkflowDraft,
1666
1688
  createLifecycleEvent,
1667
1689
  createWorkflowDocument,
1668
1690
  defineCollection,
@@ -1672,8 +1694,7 @@ function defineConfig(config) {
1672
1694
  dispatchPendingLifecycleEvents,
1673
1695
  executeFieldAfterRead,
1674
1696
  executeFieldBeforeChange,
1675
- generateAIPrompt,
1676
- generateFreshSetupPrompt,
1697
+ generateOpenApi,
1677
1698
  initializeWorkflowDocument,
1678
1699
  materializeWorkflowDocument,
1679
1700
  normalizeConfig,