@dyrected/core 1.0.9 → 2.1.0

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
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,15 +17,25 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
21
31
  var index_exports = {};
22
32
  __export(index_exports, {
33
+ createDyrectedApp: () => createDyrectedApp,
23
34
  defineCollection: () => defineCollection,
24
35
  defineConfig: () => defineConfig,
25
36
  defineGlobal: () => defineGlobal,
26
37
  generateAIPrompt: () => generateAIPrompt,
38
+ generateFreshSetupPrompt: () => generateFreshSetupPrompt,
27
39
  normalizeConfig: () => normalizeConfig
28
40
  });
29
41
  module.exports = __toCommonJS(index_exports);
@@ -36,41 +48,75 @@ function buildEnvironmentSection(frameworkLabel, isSelfHosted, config) {
36
48
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
37
49
  1. ENVIRONMENT
38
50
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
39
- - Framework : ${frameworkLabel}
51
+ - Framework : ${frameworkLabel || "Detect it"}
40
52
  - Host Type : ${isSelfHosted ? "Self-Hosted (Local/Private Server)" : "Managed (Dyrected Cloud)"}
41
53
  - API Base : ${config.baseUrl || "http://localhost:3000"}
42
54
  ${credentialLines}`;
43
55
  }
44
- function buildDiagnosticSection(existingSite) {
45
- if (existingSite) {
46
- return `
56
+ function buildDiagnosticSection() {
57
+ return `
47
58
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
48
- 2. PHASE 0 \u2014 DIAGNOSTIC (EXISTING SITE)
59
+ 2. PHASE 0 \u2014 DISCOVERY
49
60
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
50
- Before writing any code, scan the existing codebase and report:
51
- - All hardcoded text strings that should be CMS-managed
52
- - All repeated data structures that should become collections
53
- - All static pages that marketing will want to edit independently
54
- - Any existing fetch or API calls that overlap with Dyrected
61
+ Before writing any code, you MUST ask the user these questions.
62
+ Write them exactly as shown below \u2014 in plain language with examples.
63
+ Wait for the user to answer ALL of them before proceeding.
55
64
 
56
- Then propose a backup plan: extract all current content into a
57
- migration/ folder as structured .md or .json files BEFORE
58
- modifying any code.
65
+ \u2500\u2500\u2500 QUESTION 1 \u2500\u2500\u2500
66
+ Ask:
67
+ "Do you already have a website built, or are we starting fresh?
59
68
 
60
- Do NOT write any implementation code until you have reported
61
- your findings and the user has confirmed the content plan.`;
62
- }
63
- return `
64
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
65
- 2. PHASE 0 \u2014 DISCOVERY (NEW SITE)
66
- \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
67
- This is a new project. Before designing the content model, ask:
68
- - "What are your core content types? (e.g. Services, Team, Blog, Projects)"
69
- - "Which pages should marketing manage independently with blocks?"
70
- - "Are there any pages that must remain static and never become CMS-managed?"
71
- - "What is the primary goal of the site \u2014 marketing, e-commerce, portfolio, SaaS?"
69
+ \u2192 Already built: share your project or describe what pages you have
70
+ (e.g. Home, About, Services, Contact)
71
+ \u2192 Starting fresh: just say 'new site' and describe what the site is for"
72
+
73
+ \u2500\u2500\u2500 QUESTION 2 \u2500\u2500\u2500
74
+ Ask:
75
+ "What kind of content will your client need to update regularly?
76
+
77
+ Here are some examples to help you think:
78
+ \u2192 Blog posts or news articles
79
+ \u2192 Team member profiles (name, photo, bio)
80
+ \u2192 Services or product descriptions
81
+ \u2192 Testimonials or reviews
82
+ \u2192 Event listings
83
+ \u2192 FAQs
84
+ \u2192 Homepage text (headline, hero image, call-to-action button)
85
+
86
+ Just list the ones that apply. You can say things like:
87
+ 'They need to update blog posts and team members'"
88
+
89
+ \u2500\u2500\u2500 QUESTION 3 \u2500\u2500\u2500
90
+ Ask:
91
+ "Are there any pages on the site that should NEVER change \u2014 like a
92
+ custom-coded page you want to leave exactly as it is?
72
93
 
73
- Do NOT write any code until the user has answered these questions.`;
94
+ Example answer: 'The homepage has a custom animation, leave that alone.
95
+ Everything else can be managed from the CMS.'
96
+
97
+ If everything should be manageable, just say 'all pages'"
98
+
99
+ \u2500\u2500\u2500 QUESTION 4 \u2500\u2500\u2500
100
+ Ask:
101
+ "What is this website for? Pick the closest description:
102
+
103
+ A) A business or agency marketing site (show services, get leads)
104
+ B) A blog or content site (publish articles regularly)
105
+ C) A SaaS or product site (landing page, pricing, features)
106
+ D) A portfolio (show work, case studies)
107
+ E) An e-commerce or product catalogue
108
+ F) Something else \u2014 describe it in one sentence"
109
+
110
+ \u2500\u2500\u2500 IF EXISTING SITE \u2500\u2500\u2500
111
+ If the user confirms they have an existing site, also:
112
+ - Scan the codebase for all hardcoded text that should be CMS-managed
113
+ - Identify repeated data structures that should become collections
114
+ - Propose saving current content to a migration/ folder as .json files
115
+ BEFORE touching any code
116
+ - Report your findings to the user and get confirmation before proceeding
117
+
118
+ Do NOT write any implementation code until all questions are answered
119
+ and the user has confirmed the content plan.`;
74
120
  }
75
121
  function buildConstraintsSection() {
76
122
  return `
@@ -100,7 +146,7 @@ function buildSchemaRulesSection() {
100
146
  - Never drop existing fields from the schema. Mark unused fields as deprecated only.
101
147
  - All new fields must have a defaultValue.
102
148
  - Never rename a field slug \u2014 add a new field and migrate data separately.
103
- - Run npx @dyrected/cli sync:schema after every config change.`;
149
+ - For Cloud deployments, run npx @dyrected/cli sync:schema after every config change. Self-hosted deployments sync automatically on startup.`;
104
150
  }
105
151
  function buildDoNotSection() {
106
152
  return `
@@ -111,7 +157,7 @@ function buildDoNotSection() {
111
157
  - Do NOT add custom auth middleware to the admin route.
112
158
  Dyrected handles admin authentication internally. Do not wrap,
113
159
  protect, or redirect the admin route yourself.
114
- - Do NOT use renderAdminUI in a Nuxt project. Use the DyrectedAdmin
160
+ - Do NOT use renderAdminUI in a Nuxt.js project. Use the DyrectedAdmin
115
161
  component which is auto-imported by @dyrected/nuxt.
116
162
  - Do NOT modify or overwrite existing pages without first preserving their data.
117
163
  - Do NOT drop, rename, or remove fields from an existing schema.
@@ -126,23 +172,26 @@ function buildTechnicalReferenceSection() {
126
172
  Use defineCollection, defineGlobal, and defineConfig from '@dyrected/core'.
127
173
 
128
174
  FIELD TYPES:
129
- - Primitive : text | textarea | richText | number | boolean | date | email | url | json
175
+ - Primitive : text | textarea | richText | number | boolean | date | email | url | json | image
130
176
  - Choice : select | multiSelect (requires options: [{ label, value }])
131
177
  - Structural : array | object (requires nested fields: [...])
132
- - Relation : relationship (requires collection: '<slug>')
178
+ - Relation : relationship (requires relationTo: '<slug>')
133
179
  - Media : relationship to an upload collection (e.g. 'media')
134
180
  - Blocks : blocks (requires blocks: [{ slug, labels, fields }])
135
181
 
136
182
  COLLECTION OPTIONS:
137
183
  - upload: true \u2014 media library with file upload support
138
184
  - auth: true \u2014 adds login/me endpoints; password field is auto-managed
185
+ - audit: true \u2014 enables activity logging
139
186
  - admin.useAsTitle \u2014 field used as display title in admin list view
140
187
  - admin.group \u2014 groups collection under a sidebar heading
141
188
  - admin.hidden \u2014 hides collection from the sidebar (internal/system use)
142
189
 
143
190
  FIELD OPTIONS:
191
+ - label \u2014 user-friendly display name (REQUIRED for all fields)
144
192
  - required \u2014 validation
145
193
  - unique \u2014 database-level uniqueness constraint
194
+ - hasMany \u2014 allow multiple values (for relationship, select, image)
146
195
  - defaultValue \u2014 fallback value (required on all new fields added to existing schemas)
147
196
  - admin.condition \u2014 Jexl expression string to conditionally show/hide field
148
197
  e.g. "status == \\"published\\""
@@ -164,14 +213,13 @@ Always include a default case in your switch for unknown block types.
164
213
  COMPLETE SCHEMA EXAMPLE:
165
214
  \`\`\`ts
166
215
  import { defineCollection, defineGlobal, defineConfig } from '@dyrected/core'
167
- import { MongoAdapter } from '@dyrected/db-mongodb'
168
- import { S3StorageAdapter } from '@dyrected/storage-s3'
216
+ import { SqliteAdapter } from '@dyrected/db-sqlite'
169
217
 
170
218
  const media = defineCollection({
171
219
  slug: 'media',
172
220
  upload: true,
173
221
  fields: [
174
- { name: 'alt', type: 'text', label: 'Alt Text' },
222
+ { name: 'alt', label: 'Alt Text', type: 'text' },
175
223
  ],
176
224
  })
177
225
 
@@ -179,44 +227,45 @@ const pages = defineCollection({
179
227
  slug: 'pages',
180
228
  admin: { useAsTitle: 'title', group: 'Content' },
181
229
  fields: [
182
- { name: 'title', type: 'text', required: true },
183
- { name: 'slug', type: 'text', required: true, unique: true },
184
- { name: 'seo', type: 'object', fields: [
185
- { name: 'metaTitle', type: 'text' },
186
- { name: 'metaDescription', type: 'textarea' },
187
- { name: 'ogImage', type: 'relationship', collection: 'media' },
230
+ { name: 'title', label: 'Title', type: 'text', required: true },
231
+ { name: 'slug', label: 'URL Slug', type: 'text', required: true, unique: true },
232
+ { name: 'seo', label: 'SEO Metadata', type: 'object', fields: [
233
+ { name: 'metaTitle', label: 'Meta Title', type: 'text' },
234
+ { name: 'metaDescription', label: 'Meta Description', type: 'textarea' },
235
+ { name: 'ogImage', label: 'OG Image', type: 'relationship', relationTo: 'media' },
188
236
  ]},
189
237
  {
190
238
  name: 'layout',
239
+ label: 'Page Layout',
191
240
  type: 'blocks',
192
241
  blocks: [
193
242
  {
194
243
  slug: 'hero',
195
244
  labels: { singular: 'Hero', plural: 'Heroes' },
196
245
  fields: [
197
- { name: 'heading', type: 'text', required: true },
198
- { name: 'subheading', type: 'textarea' },
199
- { name: 'image', type: 'relationship', collection: 'media' },
200
- { name: 'ctaLabel', type: 'text' },
201
- { name: 'ctaLink', type: 'url' },
246
+ { name: 'heading', label: 'Heading', type: 'text', required: true },
247
+ { name: 'subheading', label: 'Subheading', type: 'textarea' },
248
+ { name: 'image', label: 'Hero Image', type: 'relationship', relationTo: 'media' },
249
+ { name: 'ctaLabel', label: 'Button Label', type: 'text' },
250
+ { name: 'ctaLink', label: 'Button Link', type: 'url' },
202
251
  ],
203
252
  },
204
253
  {
205
254
  slug: 'richContent',
206
255
  labels: { singular: 'Rich Content', plural: 'Rich Content Blocks' },
207
256
  fields: [
208
- { name: 'content', type: 'richText', required: true },
257
+ { name: 'content', label: 'Content', type: 'richText', required: true },
209
258
  ],
210
259
  },
211
260
  {
212
261
  slug: 'callToAction',
213
262
  labels: { singular: 'Call to Action', plural: 'Calls to Action' },
214
263
  fields: [
215
- { name: 'heading', type: 'text', required: true },
216
- { name: 'description', type: 'textarea' },
217
- { name: 'buttonLabel', type: 'text' },
218
- { name: 'buttonLink', type: 'url' },
219
- { name: 'theme', type: 'select', options: [
264
+ { name: 'heading', label: 'Heading', type: 'text', required: true },
265
+ { name: 'description', label: 'Description', type: 'textarea' },
266
+ { name: 'buttonLabel', label: 'Button Text', type: 'text' },
267
+ { name: 'buttonLink', label: 'Button Link', type: 'url' },
268
+ { name: 'theme', label: 'Theme', type: 'select', options: [
220
269
  { label: 'Primary', value: 'primary' },
221
270
  { label: 'Secondary', value: 'secondary' },
222
271
  { label: 'Dark', value: 'dark' },
@@ -232,28 +281,17 @@ const settings = defineGlobal({
232
281
  slug: 'settings',
233
282
  label: 'Site Settings',
234
283
  fields: [
235
- { name: 'siteName', type: 'text' },
236
- { name: 'tagline', type: 'text' },
237
- { name: 'logo', type: 'relationship', collection: 'media' },
238
- { name: 'footerText', type: 'textarea' },
284
+ { name: 'siteName', label: 'Site Name', type: 'text' },
285
+ { name: 'tagline', label: 'Site Tagline', type: 'text' },
286
+ { name: 'logo', label: 'Site Logo', type: 'relationship', relationTo: 'media' },
287
+ { name: 'footerText', label: 'Footer Text', type: 'textarea' },
239
288
  ],
240
289
  })
241
290
 
242
291
  export default defineConfig({
243
292
  collections: [media, pages],
244
- globals: [settings],
245
- db: new MongoAdapter({
246
- url: process.env.DATABASE_URL!,
247
- dbName: 'dyrected_cms',
248
- }),
249
- storage: new S3StorageAdapter({
250
- bucket: process.env.S3_BUCKET!,
251
- region: process.env.S3_REGION!,
252
- credentials: {
253
- accessKeyId: process.env.S3_ACCESS_KEY_ID!,
254
- secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
255
- },
256
- }),
293
+ globals: [settings],
294
+ db: new SqliteAdapter({ filename: './dyrected.db' }),
257
295
  })
258
296
  \`\`\``;
259
297
  }
@@ -272,7 +310,8 @@ Return your response in exactly this order. Do not combine steps. Do not skip st
272
310
  6. Migration/fallback strategy \u2014 numbered steps
273
311
  7. Schema sync command
274
312
 
275
- API Reference: https://docs.dyrected.com`;
313
+ API Reference: https://docs.dyrected.com
314
+ If you are unsure about any syntax or property, refer to the documentation above.`;
276
315
  }
277
316
  function buildFrameworkSection(activeTab, isSelfHosted, config) {
278
317
  const envPrefix = activeTab === "next" ? "NEXT_PUBLIC_" : activeTab === "nuxt" ? "NUXT_PUBLIC_" : "";
@@ -294,14 +333,14 @@ export const dyrected = createClient({
294
333
  })
295
334
  \`\`\`
296
335
 
297
- 2. Admin Route (app/admin/[[...slug]]/page.tsx):
336
+ 2. Admin Route (app/cms/page.tsx):
298
337
  \`\`\`tsx
299
338
  import { DyrectedAdmin } from '@dyrected/next/admin'
300
339
 
301
340
  export default function AdminPage() {
302
341
  // DyrectedAdmin handles routing, auth, and CSS automatically.
303
342
  // Do NOT wrap this in custom auth middleware.
304
- return <DyrectedAdmin basename="/admin" />
343
+ return <DyrectedAdmin basename="/cms" />
305
344
  }
306
345
  \`\`\`
307
346
 
@@ -326,9 +365,9 @@ export default async function CmsPage({ params }: { params: { slug: string[] } }
326
365
  \`\`\``,
327
366
  nuxt: `
328
367
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\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
- 8. IMPLEMENTATION \u2014 Nuxt
368
+ 8. IMPLEMENTATION \u2014 Nuxt.js
330
369
  \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
331
- 1. Nuxt Config (nuxt.config.ts):
370
+ 1. Nuxt.js Config (nuxt.config.ts):
332
371
  \`\`\`ts
333
372
  export default defineNuxtConfig({
334
373
  modules: ['@dyrected/nuxt'],
@@ -340,7 +379,7 @@ export default defineNuxtConfig({
340
379
  })
341
380
  \`\`\`
342
381
 
343
- 2. Admin Route (pages/admin.vue):
382
+ 2. Admin Route (pages/cms/index.vue):
344
383
  \`\`\`vue
345
384
  <script setup lang="ts">
346
385
  // DyrectedAdmin is auto-imported by @dyrected/nuxt.
@@ -352,7 +391,7 @@ definePageMeta({ layout: false })
352
391
 
353
392
  <template>
354
393
  <ClientOnly>
355
- <DyrectedAdmin basename="/admin" />
394
+ <DyrectedAdmin basename="/cms" />
356
395
  </ClientOnly>
357
396
  </template>
358
397
  \`\`\`
@@ -399,7 +438,7 @@ export const dyrected = createClient({
399
438
  })
400
439
  \`\`\`
401
440
 
402
- 2. Admin Route (pages/admin.tsx):
441
+ 2. Admin Route (pages/cms.tsx):
403
442
  \`\`\`tsx
404
443
  import { AdminUI } from '@dyrected/admin'
405
444
  import '@dyrected/admin/styles'
@@ -431,7 +470,7 @@ export const dyrected = createClient({
431
470
  })
432
471
  \`\`\`
433
472
 
434
- 2. Admin Route (pages/admin.vue):
473
+ 2. Admin Route (pages/cms.vue):
435
474
  \`\`\`vue
436
475
  <template>
437
476
  <div ref="container" style="height: 100vh" />
@@ -459,14 +498,350 @@ onUnmounted(() => cleanup?.())
459
498
  };
460
499
  return sections[activeTab] || sections.next;
461
500
  }
501
+ function generateFreshSetupPrompt(activeTab, config) {
502
+ const frameworkLabel = activeTab === "next" ? "Next.js" : activeTab === "nuxt" ? "Nuxt.js" : activeTab ? activeTab.charAt(0).toUpperCase() + activeTab.slice(1) : "the project's detected framework";
503
+ const isSelfHosted = config.isSelfHosted === true || !config.apiKey && !config.siteId;
504
+ const frameworkSetup = {
505
+ nuxt: `
506
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
507
+ 3. INSTALLATION STEPS \u2014 Run these in order
508
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
509
+ Tell the user to follow these steps exactly. Explain each one in plain
510
+ language before showing the command. Do not skip any step.
511
+
512
+ STEP 1 \u2014 Initialize Dyrected
513
+ Tell the user:
514
+ "Run this command in your terminal inside your Nuxt.js project folder.
515
+ It will set everything up for you automatically."
516
+
517
+ \`\`\`bash
518
+ npx @dyrected/cli init
519
+ \`\`\`
520
+
521
+ When it asks questions, tell the user to:
522
+ - Choose: Nuxt.js 3
523
+ - Choose: SQLite (easiest option, no extra setup needed)
524
+
525
+ The CLI will automatically:
526
+ - Install all required packages
527
+ - Create a dyrected.config.ts file
528
+ - Mount the Admin UI at pages/cms/index.vue
529
+ - Generate a .env.example file
530
+
531
+ STEP 2 \u2014 Register the module
532
+ Tell the user to open nuxt.config.ts and add '@dyrected/nuxt' to modules:
533
+
534
+ \`\`\`ts
535
+ export default defineNuxtConfig({
536
+ modules: ['@dyrected/nuxt'],
537
+ })
538
+ \`\`\`
539
+
540
+ STEP 3 \u2014 Set up environment variables
541
+ Tell the user:
542
+ "Find the file called .env.example in your project.
543
+ Make a copy of it and rename the copy to .env
544
+ Then open .env and fill in the values."
545
+
546
+ STEP 4 \u2014 Start the project
547
+ \`\`\`bash
548
+ pnpm dev
549
+ \`\`\`
550
+
551
+ STEP 5 \u2014 Open the dashboard
552
+ Tell the user to open their browser and go to:
553
+ http://localhost:3000/cms
554
+
555
+ If they see the Dyrected admin dashboard, the installation worked.
556
+ Tell them: "You are ready. Now let's set up your content."`,
557
+ next: `
558
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
559
+ 3. INSTALLATION STEPS \u2014 Run these in order
560
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
561
+ Tell the user to follow these steps exactly. Explain each one in plain
562
+ language before showing the command. Do not skip any step.
563
+
564
+ STEP 1 \u2014 Initialize Dyrected
565
+ Tell the user:
566
+ "Run this command in your terminal inside your Next.js project folder."
567
+
568
+ \`\`\`bash
569
+ npx @dyrected/cli init
570
+ \`\`\`
571
+
572
+ When it asks questions, tell the user to:
573
+ - Choose: Next.js
574
+ - Choose: SQLite (easiest option, no extra setup needed)
575
+
576
+ The CLI will automatically:
577
+ - Install all required packages
578
+ - Create a dyrected.config.ts file
579
+ - Mount the Admin UI at app/cms/page.tsx
580
+ - Generate a .env.example file
581
+
582
+ STEP 2 \u2014 Set up environment variables
583
+ Tell the user:
584
+ "Find the file called .env.example in your project.
585
+ Make a copy of it and rename the copy to .env.local
586
+ Then open .env.local and fill in the values."
587
+
588
+ STEP 3 \u2014 Start the project
589
+ \`\`\`bash
590
+ pnpm dev
591
+ \`\`\`
592
+
593
+ STEP 4 \u2014 Open the dashboard
594
+ Tell the user to open their browser and go to:
595
+ http://localhost:3000/cms
596
+
597
+ If they see the Dyrected admin dashboard, the installation worked.
598
+ Tell them: "You are ready. Now let's set up your content."`,
599
+ react: `
600
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
601
+ 3. INSTALLATION STEPS \u2014 Run these in order
602
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
603
+ STEP 1 \u2014 Install the SDK and admin packages
604
+ \`\`\`bash
605
+ npm install @dyrected/sdk @dyrected/admin
606
+ \`\`\`
607
+
608
+ STEP 2 \u2014 Set up the client (lib/dyrected.ts):
609
+ \`\`\`ts
610
+ import { createClient } from '@dyrected/sdk'
611
+
612
+ export const dyrected = createClient({
613
+ baseUrl: '${config.baseUrl || "https://api.dyrected.cloud"}',${isSelfHosted ? "" : `
614
+ apiKey: '${config.apiKey}',
615
+ siteId: '${config.siteId}',`}
616
+ })
617
+ \`\`\`
618
+
619
+ STEP 3 \u2014 Mount the Admin UI (pages/cms.tsx):
620
+ \`\`\`tsx
621
+ import { AdminUI } from '@dyrected/admin'
622
+ import '@dyrected/admin/styles'
623
+
624
+ export default function AdminPage() {
625
+ return (
626
+ <div style={{ height: '100vh' }}>
627
+ <AdminUI
628
+ baseUrl="${config.baseUrl || "https://api.dyrected.cloud"}"${isSelfHosted ? "" : `
629
+ apiKey="${config.apiKey}"
630
+ siteId="${config.siteId}"`}
631
+ />
632
+ </div>
633
+ )
634
+ }
635
+ \`\`\``,
636
+ vue: `
637
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
638
+ 3. INSTALLATION STEPS \u2014 Run these in order
639
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
640
+ STEP 1 \u2014 Install the SDK and admin packages
641
+ \`\`\`bash
642
+ npm install @dyrected/sdk @dyrected/admin
643
+ \`\`\`
644
+
645
+ STEP 2 \u2014 Set up the client (lib/dyrected.ts):
646
+ \`\`\`ts
647
+ import { createClient } from '@dyrected/sdk'
648
+
649
+ export const dyrected = createClient({
650
+ baseUrl: '${config.baseUrl || "https://api.dyrected.cloud"}',${isSelfHosted ? "" : `
651
+ apiKey: '${config.apiKey}',
652
+ siteId: '${config.siteId}',`}
653
+ })
654
+ \`\`\`
655
+
656
+ STEP 3 \u2014 Mount the Admin UI (pages/cms.vue):
657
+ \`\`\`vue
658
+ <template>
659
+ <div ref="container" style="height: 100vh" />
660
+ </template>
661
+
662
+ <script setup>
663
+ import { ref, onMounted, onUnmounted } from 'vue'
664
+ import { renderAdminUI } from '@dyrected/admin'
665
+ import '@dyrected/admin/styles'
666
+
667
+ const container = ref(null)
668
+ let cleanup
669
+
670
+ onMounted(() => {
671
+ cleanup = renderAdminUI(container.value, {
672
+ baseUrl: '${config.baseUrl || "https://api.dyrected.cloud"}',${isSelfHosted ? "" : `
673
+ apiKey: '${config.apiKey}',
674
+ siteId: '${config.siteId}',`}
675
+ })
676
+ })
677
+
678
+ onUnmounted(() => cleanup?.())
679
+ </script>
680
+ \`\`\``
681
+ };
682
+ return [
683
+ `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.
684
+
685
+ Your job is to:
686
+ 1. Understand who you are talking to before giving any instructions
687
+ 2. Ask simple questions about their project and content needs
688
+ 3. Walk them through setup in a way that matches their technical level
689
+ 4. Confirm each step worked before moving to the next
690
+ 5. Help them design their content so their client can manage it
691
+
692
+ Speak in plain language at all times. Never assume technical knowledge.
693
+ Never show more than one step at a time.
694
+ Never mention the terminal, command line, or any commands until you have
695
+ confirmed the user is comfortable running them.`,
696
+ `
697
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
698
+ 1. ENVIRONMENT
699
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
700
+ - Framework : ${frameworkLabel}
701
+ - Host Type : ${isSelfHosted ? "Self-Hosted" : "Dyrected Cloud"}
702
+ - API Base : ${config.baseUrl || "http://localhost:3000"}
703
+ ${isSelfHosted ? "" : `- Site ID : ${config.siteId}
704
+ - API Key : ${config.apiKey}`}`.trim(),
705
+ `
706
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
707
+ 2. PHASE 0 \u2014 UNDERSTAND THE USER FIRST
708
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
709
+ Ask these questions one at a time. Wait for each answer before asking the next.
710
+ Never ask more than one question at a time.
711
+
712
+ \u2500\u2500\u2500 QUESTION 1 \u2014 TECH LEVEL \u2500\u2500\u2500
713
+ This is the most important question. Ask it first.
714
+
715
+ Ask exactly this:
716
+ "Before we dive in \u2014 how would you describe yourself?
717
+
718
+ A) I write code myself (I'm comfortable with the terminal and editing files)
719
+ B) I use tools like Lovable, Bolt, or v0 to build with AI \u2014 I don't write much code myself
720
+ C) I'm a designer or project manager \u2014 someone else usually handles the technical stuff
721
+ D) Something else \u2014 just describe how you work"
722
+
723
+ Use their answer to decide how to guide them for the rest of the setup:
724
+ - If A \u2192 they are TECHNICAL. You may show terminal commands and code directly.
725
+ - If B \u2192 they are SEMI-TECHNICAL. Explain what each step does before showing
726
+ any code or commands. Ask before running anything in the terminal.
727
+ For Lovable users specifically: guide them to use the built-in
728
+ terminal or ask them to paste code into the right file in their editor.
729
+ - If C \u2192 they are NON-TECHNICAL. Do not show terminal commands at all.
730
+ Generate all the code and config for them. Walk them through
731
+ copy-pasting into specific files by name. If a terminal step is
732
+ unavoidable, warn them first and offer to write the exact command
733
+ with a clear explanation of what it does and where to run it.
734
+ - If D \u2192 ask one follow-up question to understand their workflow before
735
+ deciding which path above fits best.
736
+
737
+ \u2500\u2500\u2500 QUESTION 2 \u2014 PROJECT STATUS \u2500\u2500\u2500
738
+ Ask after Q1 is answered:
739
+
740
+ "Do you already have a ${frameworkLabel} project open,
741
+ or are we starting from scratch?
742
+
743
+ \u2192 Already have a project: tell me what the site is about or
744
+ share the folder name
745
+ \u2192 Starting fresh: just say 'new project' and I will help you
746
+ create one first"
747
+
748
+ \u2500\u2500\u2500 QUESTION 3 \u2014 SITE PURPOSE \u2500\u2500\u2500
749
+ Ask after Q2 is answered:
750
+
751
+ "What kind of website is this?
752
+
753
+ A) A business or agency site (show services, get enquiries)
754
+ B) A blog or news site (publish articles regularly)
755
+ C) A SaaS or product landing page (features, pricing, sign up)
756
+ D) A portfolio (show work and past projects)
757
+ E) Something else \u2014 describe it in one sentence"
758
+
759
+ \u2500\u2500\u2500 QUESTION 4 \u2014 CONTENT NEEDS \u2500\u2500\u2500
760
+ Ask after Q3 is answered:
761
+
762
+ "What will your client need to update themselves \u2014 without calling you?
763
+
764
+ Some examples to help you think:
765
+ \u2192 Blog posts or news articles
766
+ \u2192 Team member profiles (name, photo, bio)
767
+ \u2192 Services or product descriptions
768
+ \u2192 Homepage text (headline, buttons, images)
769
+ \u2192 Testimonials or reviews
770
+ \u2192 FAQs
771
+ \u2192 Event listings or announcements
772
+
773
+ Just list the ones that apply. Or say 'not sure yet'
774
+ and we will figure it out together."
775
+
776
+ \u2500\u2500\u2500 AFTER ALL QUESTIONS \u2500\u2500\u2500
777
+ Once all four questions are answered:
778
+ 1. Summarise what you understood in plain English
779
+ 2. Ask the user to confirm before touching any code
780
+ 3. Then move to the installation steps using the correct path
781
+ for their tech level from Question 1`,
782
+ frameworkSetup[activeTab] || frameworkSetup.nuxt,
783
+ `
784
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
785
+ 4. AFTER INSTALLATION \u2014 CONTENT SETUP
786
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
787
+ Once the dashboard is confirmed working, help the user design their
788
+ content model in dyrected.config.ts based on what they told you in
789
+ Phase 0.
790
+
791
+ RULES for this phase:
792
+ - Use defineCollection and defineConfig from '@dyrected/core'
793
+ - Use client.collection(slug) only \u2014 never client.collections
794
+ - Always use initialData in all data fetches
795
+ - Use a catch-all pages collection for marketing-managed pages
796
+ - Use blocks for flexible page layouts
797
+ - Use type: 'relationship', relationTo: 'collectionSlug' for relations
798
+ - Never throw during render \u2014 fall back to initialData on errors
799
+ - All relationship fields must handle null gracefully
800
+
801
+ CONTENT SETUP DELIVERABLES \u2014 in this order:
802
+ 1. dyrected.config.ts \u2014 complete file based on their answers
803
+ 2. Catch-all page route for CMS-managed pages
804
+ 3. Block components list (names and fields only)
805
+ 4. One example fetch showing how to load content on a page
806
+
807
+ After delivering the config, tell the user to sync their schema.
808
+ For TECHNICAL users show the command directly:
809
+ \`\`\`bash
810
+ npx @dyrected/cli sync:schema
811
+ \`\`\`
812
+ For SEMI-TECHNICAL or NON-TECHNICAL users explain it first:
813
+ "This next step tells Dyrected to read your content setup file
814
+ and prepare the database. Here is the command to run:" then show it.
815
+
816
+ Then ask: "Do you want me to help you connect this content to your
817
+ frontend pages now?"
818
+
819
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
820
+ DO NOT
821
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
822
+ - Do NOT show terminal commands before confirming the user's tech level
823
+ - Do NOT assume the user knows what a terminal, CLI, or package manager is
824
+ - Do NOT show all steps at once \u2014 one step at a time always
825
+ - Do NOT use client.collections \u2014 use client.collection(slug)
826
+ - Do NOT add custom auth middleware to the admin route
827
+ - Do NOT use renderAdminUI in a Nuxt.js project
828
+ - Do NOT skip the confirmation after each installation step
829
+ - Do NOT use jargon without explaining it in plain English first
830
+ - Do NOT assume the installation worked \u2014 ask the user to confirm
831
+
832
+ API Reference: https://docs.dyrected.com
833
+ If you are unsure about any syntax, refer to the official documentation above.`
834
+ ].join("\n");
835
+ }
462
836
  function generateAIPrompt(activeTab, config) {
463
- const frameworkLabel = activeTab === "next" ? "Next.js" : activeTab === "nuxt" ? "Nuxt" : activeTab.charAt(0).toUpperCase() + activeTab.slice(1);
837
+ const frameworkLabel = activeTab === "next" ? "Next.js" : activeTab === "nuxt" ? "Nuxt.js" : activeTab ? activeTab.charAt(0).toUpperCase() + activeTab.slice(1) : "the project's detected framework";
464
838
  const isSelfHosted = config.isSelfHosted === true || !config.apiKey && !config.siteId;
465
839
  const existingSite = config.existingSite ?? false;
840
+ 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.`;
466
841
  const sections = [
467
- `You are a Senior Content Architect. Your mission is to integrate Dyrected CMS into a ${frameworkLabel} project. Your priority is DATA PRESERVATION and creating a CMS that empowers marketing teams to move independently without raising tickets to engineering.`,
842
+ missionText,
468
843
  buildEnvironmentSection(frameworkLabel, isSelfHosted, config),
469
- buildDiagnosticSection(existingSite),
844
+ buildDiagnosticSection(),
470
845
  buildConstraintsSection(),
471
846
  buildSchemaRulesSection(),
472
847
  buildDoNotSection(),
@@ -540,6 +915,1719 @@ function normalizeConfig(config) {
540
915
  };
541
916
  }
542
917
 
918
+ // src/app.ts
919
+ var import_hono = require("hono");
920
+ var import_cors = require("hono/cors");
921
+ var import_request_id = require("hono/request-id");
922
+
923
+ // src/services/population.service.ts
924
+ var PopulationService = class {
925
+ db;
926
+ collections;
927
+ constructor(db, collections) {
928
+ this.db = db;
929
+ this.collections = collections;
930
+ }
931
+ /**
932
+ * Recursively populate relationship fields in a document or array of documents.
933
+ */
934
+ async populate(args) {
935
+ const { data, fields, currentDepth, maxDepth } = args;
936
+ if (currentDepth >= maxDepth || !data) {
937
+ return data;
938
+ }
939
+ if (Array.isArray(data)) {
940
+ return Promise.all(data.map((item) => this.populate({ ...args, data: item })));
941
+ }
942
+ const populatedDoc = { ...data };
943
+ for (const field of fields) {
944
+ const value = populatedDoc[field.name];
945
+ if (field.type === "relationship" && field.relationTo && value) {
946
+ const relatedCollection = this.collections.find((c) => c.slug === field.relationTo);
947
+ if (!relatedCollection) continue;
948
+ if (Array.isArray(value)) {
949
+ populatedDoc[field.name] = await Promise.all(
950
+ value.map(async (id) => {
951
+ if (!id) return id;
952
+ let doc = id;
953
+ if (typeof id === "string") {
954
+ doc = await this.db.findOne({ collection: field.relationTo, id });
955
+ }
956
+ if (!doc || typeof doc !== "object") return id;
957
+ return this.populate({
958
+ data: doc,
959
+ fields: relatedCollection.fields,
960
+ currentDepth: currentDepth + 1,
961
+ maxDepth
962
+ });
963
+ })
964
+ );
965
+ } else if (value) {
966
+ let doc = value;
967
+ if (typeof value === "string") {
968
+ doc = await this.db.findOne({ collection: field.relationTo, id: value });
969
+ }
970
+ if (doc && typeof doc === "object") {
971
+ populatedDoc[field.name] = await this.populate({
972
+ data: doc,
973
+ fields: relatedCollection.fields,
974
+ currentDepth: currentDepth + 1,
975
+ maxDepth
976
+ });
977
+ }
978
+ }
979
+ }
980
+ if ((field.type === "array" || field.type === "object") && field.fields && value) {
981
+ populatedDoc[field.name] = await this.populate({
982
+ data: value,
983
+ fields: field.fields,
984
+ currentDepth,
985
+ // Nested fields don't consume depth, only relationships do
986
+ maxDepth
987
+ });
988
+ }
989
+ if (field.type === "blocks" && field.blocks && Array.isArray(value)) {
990
+ populatedDoc[field.name] = await Promise.all(
991
+ value.map(async (blockData) => {
992
+ const blockConfig = field.blocks.find((b) => b.slug === blockData.blockType);
993
+ if (!blockConfig) return blockData;
994
+ return this.populate({
995
+ data: blockData,
996
+ fields: blockConfig.fields,
997
+ currentDepth,
998
+ maxDepth
999
+ });
1000
+ })
1001
+ );
1002
+ }
1003
+ }
1004
+ return populatedDoc;
1005
+ }
1006
+ /**
1007
+ * Helper to populate a PaginatedResult
1008
+ */
1009
+ async populateResult(result, fields, maxDepth) {
1010
+ if (maxDepth <= 0) return result;
1011
+ const populatedDocs = await this.populate({
1012
+ data: result.docs,
1013
+ fields,
1014
+ currentDepth: 0,
1015
+ maxDepth
1016
+ });
1017
+ return {
1018
+ ...result,
1019
+ docs: populatedDocs
1020
+ };
1021
+ }
1022
+ };
1023
+
1024
+ // src/services/defaults.service.ts
1025
+ var DefaultsService = class {
1026
+ /**
1027
+ * Recursively apply default values to a data object based on field definitions.
1028
+ */
1029
+ static apply(fields, data = {}) {
1030
+ const result = { ...data || {} };
1031
+ fields.forEach((field) => {
1032
+ const value = result[field.name];
1033
+ if (value === void 0 || value === null) {
1034
+ if (field.defaultValue !== void 0) {
1035
+ result[field.name] = field.defaultValue;
1036
+ } else {
1037
+ if (field.type === "boolean") result[field.name] = false;
1038
+ else if (field.type === "array") result[field.name] = [];
1039
+ else if (field.type === "multiSelect") result[field.name] = [];
1040
+ else if (field.type === "object") {
1041
+ result[field.name] = this.apply(field.fields || [], {});
1042
+ }
1043
+ }
1044
+ } else if (field.type === "object" && field.fields) {
1045
+ result[field.name] = this.apply(field.fields, value);
1046
+ } else if (field.type === "array" && field.fields && Array.isArray(value)) {
1047
+ result[field.name] = value.map((item) => this.apply(field.fields, item));
1048
+ }
1049
+ });
1050
+ return result;
1051
+ }
1052
+ };
1053
+
1054
+ // src/services/audit.service.ts
1055
+ var AuditService = class {
1056
+ /**
1057
+ * Writes a single entry to the __audit collection.
1058
+ * Called without await — runs asynchronously and never blocks the primary operation.
1059
+ */
1060
+ static async log(db, args) {
1061
+ try {
1062
+ await db.create({
1063
+ collection: "__audit",
1064
+ data: {
1065
+ collection: args.collection,
1066
+ documentId: args.documentId ?? null,
1067
+ operation: args.operation,
1068
+ user: args.user?.id ?? null,
1069
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1070
+ changes: JSON.stringify({
1071
+ before: args.before ?? null,
1072
+ after: args.after ?? null
1073
+ })
1074
+ }
1075
+ });
1076
+ } catch (err) {
1077
+ console.error("[dyrected/audit] Failed to write audit log:", err);
1078
+ }
1079
+ }
1080
+ };
1081
+
1082
+ // src/controllers/collection.controller.ts
1083
+ var CollectionController = class {
1084
+ collection;
1085
+ constructor(collection) {
1086
+ this.collection = collection;
1087
+ }
1088
+ async find(c) {
1089
+ const config = c.get("config");
1090
+ const db = config.db;
1091
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1092
+ const limit = Number(c.req.query("limit")) || 10;
1093
+ const page = Number(c.req.query("page")) || 1;
1094
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
1095
+ const sort = c.req.query("sort") || void 0;
1096
+ let where = void 0;
1097
+ const whereRaw = c.req.query("where");
1098
+ if (whereRaw) {
1099
+ try {
1100
+ where = JSON.parse(decodeURIComponent(whereRaw));
1101
+ } catch {
1102
+ }
1103
+ }
1104
+ let result = await db.find({
1105
+ collection: this.collection.slug,
1106
+ limit,
1107
+ page,
1108
+ sort,
1109
+ where
1110
+ });
1111
+ result.docs = result.docs.map((doc) => DefaultsService.apply(this.collection.fields, doc));
1112
+ if (depth > 0) {
1113
+ const populationService = new PopulationService(db, config.collections);
1114
+ result = await populationService.populateResult(result, this.collection.fields, depth);
1115
+ }
1116
+ return c.json(result);
1117
+ }
1118
+ async findOne(c) {
1119
+ const config = c.get("config");
1120
+ const db = config.db;
1121
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1122
+ const id = c.req.param("id");
1123
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
1124
+ if (!id) return c.json({ message: "Missing ID" }, 400);
1125
+ const doc = await db.findOne({ collection: this.collection.slug, id });
1126
+ if (!doc) return c.json({ message: "Not Found" }, 404);
1127
+ const docWithDefaults = DefaultsService.apply(this.collection.fields, doc);
1128
+ if (depth > 0 && docWithDefaults) {
1129
+ const populationService = new PopulationService(db, config.collections);
1130
+ const populatedDoc = await populationService.populate({
1131
+ data: docWithDefaults,
1132
+ fields: this.collection.fields,
1133
+ currentDepth: 0,
1134
+ maxDepth: depth
1135
+ });
1136
+ return c.json(populatedDoc);
1137
+ }
1138
+ return c.json(docWithDefaults);
1139
+ }
1140
+ async create(c) {
1141
+ const config = c.get("config");
1142
+ const db = config.db;
1143
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1144
+ const contentType = c.req.header("Content-Type") || "";
1145
+ if (contentType.toLowerCase().includes("multipart/form-data")) {
1146
+ return this.upload(c);
1147
+ }
1148
+ const body = await c.req.json();
1149
+ const user = c.get("user");
1150
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1151
+ const data = {
1152
+ ...body,
1153
+ createdAt: now,
1154
+ updatedAt: now,
1155
+ createdBy: user?.sub ?? null,
1156
+ updatedBy: user?.sub ?? null
1157
+ };
1158
+ const doc = await db.create({ collection: this.collection.slug, data });
1159
+ if (this.collection.audit && db) {
1160
+ AuditService.log(db, {
1161
+ operation: "create",
1162
+ collection: this.collection.slug,
1163
+ documentId: doc.id,
1164
+ user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
1165
+ before: null,
1166
+ after: doc
1167
+ });
1168
+ }
1169
+ return c.json(doc, 201);
1170
+ }
1171
+ async upload(c) {
1172
+ const config = c.get("config");
1173
+ const storage = config.storage;
1174
+ if (!storage) return c.json({ message: "Storage not configured" }, 500);
1175
+ const formData = await c.req.formData();
1176
+ const file = formData.get("file");
1177
+ if (!file) return c.json({ message: "No file uploaded" }, 400);
1178
+ const buffer = new Uint8Array(await file.arrayBuffer());
1179
+ const siteId = c.get("siteId");
1180
+ const workspaceId = c.get("workspaceId");
1181
+ const prefix = workspaceId ? `${workspaceId}/${siteId}` : siteId;
1182
+ const fileData = await storage.upload({
1183
+ filename: file.name,
1184
+ buffer,
1185
+ mimeType: file.type,
1186
+ prefix
1187
+ });
1188
+ const otherData = {};
1189
+ formData.forEach((value, key) => {
1190
+ if (key !== "file" && typeof value === "string") {
1191
+ otherData[key] = value;
1192
+ }
1193
+ });
1194
+ const user = c.get("user");
1195
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1196
+ const doc = await config.db.create({
1197
+ collection: this.collection.slug,
1198
+ data: {
1199
+ ...otherData,
1200
+ ...fileData,
1201
+ createdAt: now,
1202
+ updatedAt: now,
1203
+ createdBy: user?.sub ?? null,
1204
+ updatedBy: user?.sub ?? null
1205
+ }
1206
+ });
1207
+ return c.json(doc, 201);
1208
+ }
1209
+ async update(c) {
1210
+ const config = c.get("config");
1211
+ const db = config.db;
1212
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1213
+ const id = c.req.param("id");
1214
+ if (!id) return c.json({ message: "Missing ID" }, 400);
1215
+ const body = await c.req.json();
1216
+ const user = c.get("user");
1217
+ const data = {
1218
+ ...body,
1219
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1220
+ updatedBy: user?.sub ?? null
1221
+ };
1222
+ let before = null;
1223
+ if (this.collection.audit) {
1224
+ before = await db.findOne({ collection: this.collection.slug, id });
1225
+ }
1226
+ const doc = await db.update({ collection: this.collection.slug, id, data });
1227
+ if (this.collection.audit && db) {
1228
+ AuditService.log(db, {
1229
+ operation: "update",
1230
+ collection: this.collection.slug,
1231
+ documentId: id,
1232
+ user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
1233
+ before,
1234
+ after: doc
1235
+ });
1236
+ }
1237
+ return c.json(doc);
1238
+ }
1239
+ async delete(c) {
1240
+ const config = c.get("config");
1241
+ const db = config.db;
1242
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1243
+ const id = c.req.param("id");
1244
+ if (!id) return c.json({ message: "Missing ID" }, 400);
1245
+ const user = c.get("user");
1246
+ let before = null;
1247
+ if (this.collection.audit) {
1248
+ before = await db.findOne({ collection: this.collection.slug, id });
1249
+ }
1250
+ await db.delete({ collection: this.collection.slug, id });
1251
+ if (this.collection.audit && db) {
1252
+ AuditService.log(db, {
1253
+ operation: "delete",
1254
+ collection: this.collection.slug,
1255
+ documentId: id,
1256
+ user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
1257
+ before,
1258
+ after: null
1259
+ });
1260
+ }
1261
+ return c.json({ message: "Deleted" });
1262
+ }
1263
+ async seed(c) {
1264
+ const config = c.get("config");
1265
+ const db = config.db;
1266
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1267
+ const body = await c.req.json();
1268
+ const initialData = body.data;
1269
+ if (!initialData || !Array.isArray(initialData)) {
1270
+ return c.json({ message: "Invalid initial data" }, 400);
1271
+ }
1272
+ const result = await db.find({ collection: this.collection.slug, limit: 1 });
1273
+ if (result.total > 0) {
1274
+ return c.json({ message: "Collection is not empty, skipping seed" });
1275
+ }
1276
+ console.log(`[dyrected/core] Auto-seeding collection: ${this.collection.slug}`);
1277
+ const createdDocs = [];
1278
+ for (const data of initialData) {
1279
+ const doc = await db.create({ collection: this.collection.slug, data });
1280
+ createdDocs.push(doc);
1281
+ }
1282
+ return c.json({ message: "Seed successful", count: createdDocs.length }, 201);
1283
+ }
1284
+ };
1285
+
1286
+ // src/controllers/global.controller.ts
1287
+ var GlobalController = class {
1288
+ global;
1289
+ constructor(global) {
1290
+ this.global = global;
1291
+ }
1292
+ async get(c) {
1293
+ const config = c.get("config");
1294
+ const db = config.db;
1295
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1296
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
1297
+ const data = await db.getGlobal({ slug: this.global.slug });
1298
+ const dataWithDefaults = DefaultsService.apply(this.global.fields, data);
1299
+ if (depth > 0 && dataWithDefaults) {
1300
+ const populationService = new PopulationService(db, config.collections);
1301
+ const populatedData = await populationService.populate({
1302
+ data: dataWithDefaults,
1303
+ fields: this.global.fields,
1304
+ currentDepth: 1,
1305
+ maxDepth: depth
1306
+ });
1307
+ return c.json(populatedData);
1308
+ }
1309
+ return c.json(dataWithDefaults);
1310
+ }
1311
+ async update(c) {
1312
+ const db = c.get("config").db;
1313
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1314
+ const body = await c.req.json();
1315
+ const data = await db.updateGlobal({ slug: this.global.slug, data: body });
1316
+ return c.json(data);
1317
+ }
1318
+ async seed(c) {
1319
+ const config = c.get("config");
1320
+ const db = config.db;
1321
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1322
+ const body = await c.req.json();
1323
+ const initialData = body.data;
1324
+ if (!initialData) {
1325
+ return c.json({ message: "Invalid initial data" }, 400);
1326
+ }
1327
+ const existing = await db.getGlobal({ slug: this.global.slug });
1328
+ if (existing && Object.keys(existing).length > 0) {
1329
+ return c.json({ message: "Global is not empty, skipping seed" });
1330
+ }
1331
+ console.log(`[dyrected/core] Auto-seeding global: ${this.global.slug}`);
1332
+ await db.updateGlobal({ slug: this.global.slug, data: initialData });
1333
+ return c.json({ message: "Seed successful", data: initialData }, 201);
1334
+ }
1335
+ };
1336
+
1337
+ // src/controllers/media.controller.ts
1338
+ var MediaController = class {
1339
+ collection;
1340
+ constructor(collection = "media") {
1341
+ this.collection = collection;
1342
+ }
1343
+ async upload(c) {
1344
+ const config = c.get("config");
1345
+ const storage = config.storage;
1346
+ const imageService = config.image;
1347
+ if (!storage) {
1348
+ return c.json({ message: "Storage not configured" }, 500);
1349
+ }
1350
+ const body = await c.req.parseBody();
1351
+ const file = body["file"];
1352
+ const focalPointStr = body["focalPoint"];
1353
+ const focalPoint = focalPointStr ? JSON.parse(focalPointStr) : void 0;
1354
+ if (!file) {
1355
+ return c.json({ message: "No file uploaded" }, 400);
1356
+ }
1357
+ const buffer = new Uint8Array(await file.arrayBuffer());
1358
+ const siteId = c.get("siteId");
1359
+ const workspaceId = c.get("workspaceId");
1360
+ const prefix = workspaceId ? `${workspaceId}/${siteId}` : siteId || "default";
1361
+ let imageMetadata = {};
1362
+ let imageSizes = {};
1363
+ if (imageService && file.type.startsWith("image/")) {
1364
+ let colConfig = config.collections.find((col) => col.slug === this.collection);
1365
+ if (!colConfig && config.onSchemaFetch && siteId) {
1366
+ const dynamic = await config.onSchemaFetch(siteId);
1367
+ colConfig = dynamic.collections?.find((col) => col.slug === this.collection);
1368
+ }
1369
+ try {
1370
+ const processed = await imageService.process({
1371
+ buffer,
1372
+ mimeType: file.type,
1373
+ config: colConfig?.upload,
1374
+ focalPoint
1375
+ });
1376
+ imageMetadata = processed.metadata;
1377
+ imageSizes = processed.sizes;
1378
+ } catch (err) {
1379
+ console.error("[MediaController] Image processing failed:", err);
1380
+ }
1381
+ }
1382
+ const fileData = await storage.upload({
1383
+ filename: file.name,
1384
+ buffer,
1385
+ mimeType: file.type,
1386
+ prefix
1387
+ });
1388
+ const finalFileData = {
1389
+ ...fileData,
1390
+ ...imageMetadata,
1391
+ focalPoint,
1392
+ sizes: {}
1393
+ };
1394
+ if (imageSizes) {
1395
+ for (const [sizeName, sizeData] of Object.entries(imageSizes)) {
1396
+ const ext = file.name.split(".").pop();
1397
+ const baseName = file.name.substring(0, file.name.lastIndexOf("."));
1398
+ const sizeFilename = `${baseName}-${sizeName}.${ext}`;
1399
+ try {
1400
+ const sizeFileData = await storage.upload({
1401
+ filename: sizeFilename,
1402
+ buffer: sizeData.buffer,
1403
+ mimeType: file.type,
1404
+ prefix
1405
+ });
1406
+ finalFileData.sizes[sizeName] = {
1407
+ ...sizeFileData,
1408
+ width: sizeData.width,
1409
+ height: sizeData.height
1410
+ };
1411
+ } catch (err) {
1412
+ console.error(`[MediaController] Failed to upload size ${sizeName}:`, err);
1413
+ }
1414
+ }
1415
+ }
1416
+ const db = config.db;
1417
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1418
+ const doc = await db.create({
1419
+ collection: this.collection,
1420
+ data: finalFileData
1421
+ });
1422
+ return c.json(doc, 201);
1423
+ }
1424
+ async find(c) {
1425
+ const db = c.get("config").db;
1426
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1427
+ const limit = Number(c.req.query("limit")) || 10;
1428
+ const page = Number(c.req.query("page")) || 1;
1429
+ const result = await db.find({
1430
+ collection: this.collection,
1431
+ limit,
1432
+ page
1433
+ });
1434
+ return c.json(result);
1435
+ }
1436
+ async delete(c) {
1437
+ const config = c.get("config");
1438
+ const storage = config.storage;
1439
+ const db = config.db;
1440
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1441
+ const id = c.req.param("id");
1442
+ if (!id) return c.json({ message: "Missing ID" }, 400);
1443
+ const doc = await db.findOne({ collection: this.collection, id });
1444
+ if (!doc) return c.json({ message: "Not Found" }, 404);
1445
+ if (storage) {
1446
+ await storage.delete({ filename: doc.filename });
1447
+ if (doc.sizes) {
1448
+ for (const size of Object.values(doc.sizes)) {
1449
+ if (size.filename) {
1450
+ await storage.delete({ filename: size.filename });
1451
+ }
1452
+ }
1453
+ }
1454
+ }
1455
+ await db.delete({ collection: this.collection, id });
1456
+ return c.json({ message: "Deleted" });
1457
+ }
1458
+ async serve(c) {
1459
+ const config = c.get("config");
1460
+ const storage = config.storage;
1461
+ if (!storage || !storage.resolve) {
1462
+ return c.json({ message: "Storage not configured for serving" }, 404);
1463
+ }
1464
+ const filename = c.req.param("filename");
1465
+ if (!filename) return c.json({ message: "Missing filename" }, 400);
1466
+ let res = await storage.resolve({ filename });
1467
+ if (!res && !filename.includes("/")) {
1468
+ res = await storage.resolve({ filename: `default/${filename}` });
1469
+ }
1470
+ if (!res) return c.json({ message: "Not Found" }, 404);
1471
+ c.header("Content-Type", res.mimeType);
1472
+ return c.body(res.buffer);
1473
+ }
1474
+ };
1475
+
1476
+ // src/auth/password.ts
1477
+ var import_node_util = require("util");
1478
+ var import_node_crypto = require("crypto");
1479
+ var scryptAsync = (0, import_node_util.promisify)(import_node_crypto.scrypt);
1480
+ var SALT_LEN = 16;
1481
+ var KEY_LEN = 64;
1482
+ async function hashPassword(plain) {
1483
+ const salt = (0, import_node_crypto.randomBytes)(SALT_LEN).toString("hex");
1484
+ const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
1485
+ return `${salt}:${derivedKey.toString("hex")}`;
1486
+ }
1487
+ async function verifyPassword(plain, stored) {
1488
+ const [salt, storedHash] = stored.split(":");
1489
+ if (!salt || !storedHash) return false;
1490
+ const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
1491
+ const storedBuffer = Buffer.from(storedHash, "hex");
1492
+ if (derivedKey.length !== storedBuffer.length) return false;
1493
+ return (0, import_node_crypto.timingSafeEqual)(derivedKey, storedBuffer);
1494
+ }
1495
+
1496
+ // src/auth/token.ts
1497
+ var import_jose = require("jose");
1498
+ var import_node_util2 = require("util");
1499
+ function getSecret() {
1500
+ const secret = process.env.DYRECTED_JWT_SECRET || process.env.JWT_SECRET;
1501
+ if (!secret) {
1502
+ throw new Error(
1503
+ "[dyrected/core] DYRECTED_JWT_SECRET is not set. Add it to your environment variables to enable auth collections."
1504
+ );
1505
+ }
1506
+ return new import_node_util2.TextEncoder().encode(secret);
1507
+ }
1508
+ var DEFAULT_EXPIRY = "7d";
1509
+ async function signCollectionToken(payload, expiresIn = DEFAULT_EXPIRY) {
1510
+ return new import_jose.SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiresIn).sign(getSecret());
1511
+ }
1512
+ async function verifyCollectionToken(token) {
1513
+ const { payload } = await (0, import_jose.jwtVerify)(token, getSecret());
1514
+ return payload;
1515
+ }
1516
+
1517
+ // src/services/email.service.ts
1518
+ var _devSend = null;
1519
+ var _devSendPromise = null;
1520
+ async function getDevSend() {
1521
+ if (_devSend) return _devSend;
1522
+ if (_devSendPromise) return _devSendPromise;
1523
+ _devSendPromise = (async () => {
1524
+ try {
1525
+ const nodemailer = await import("nodemailer");
1526
+ const account = await nodemailer.default.createTestAccount();
1527
+ const transport = nodemailer.default.createTransport({
1528
+ host: "smtp.ethereal.email",
1529
+ port: 587,
1530
+ auth: { user: account.user, pass: account.pass }
1531
+ });
1532
+ console.log("[dyrected/core] No email config \u2014 using Ethereal for dev email preview.");
1533
+ console.log(`[dyrected/core] Ethereal login: https://ethereal.email user: ${account.user} pass: ${account.pass}`);
1534
+ _devSend = async ({ to, subject, html }) => {
1535
+ const info = await transport.sendMail({ from: '"Dyrected Dev" <dev@dyrected.local>', to, subject, html });
1536
+ console.log(`[dyrected/core] Email preview URL: ${nodemailer.default.getTestMessageUrl(info)}`);
1537
+ };
1538
+ return _devSend;
1539
+ } catch {
1540
+ console.warn("[dyrected/core] nodemailer not available \u2014 emails will not be sent in dev.");
1541
+ return null;
1542
+ }
1543
+ })();
1544
+ return _devSendPromise;
1545
+ }
1546
+ async function sendEmail(config, payload) {
1547
+ if (config.email) {
1548
+ await config.email.send(payload);
1549
+ return;
1550
+ }
1551
+ if (process.env.NODE_ENV !== "production") {
1552
+ const devSend = await getDevSend();
1553
+ await devSend?.(payload);
1554
+ }
1555
+ }
1556
+ function buildWelcomeEmail(config, args) {
1557
+ const custom = config.email?.templates?.welcome?.(args);
1558
+ return {
1559
+ subject: custom?.subject ?? "Welcome \u2014 your account is ready",
1560
+ html: custom?.html ?? `
1561
+ <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
1562
+ <h2>Welcome!</h2>
1563
+ <p>Your account has been created. You can now log in with:</p>
1564
+ <p><strong>${args.email}</strong></p>
1565
+ </div>`
1566
+ };
1567
+ }
1568
+ function buildInviteEmail(config, args) {
1569
+ const custom = config.email?.templates?.invite?.(args);
1570
+ return {
1571
+ subject: custom?.subject ?? "You've been invited",
1572
+ html: custom?.html ?? `
1573
+ <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
1574
+ <h2>You've been invited</h2>
1575
+ ${args.invitedByEmail ? `<p>Invited by <strong>${args.invitedByEmail}</strong>.</p>` : ""}
1576
+ <p>Use the token below to accept your invitation. It expires in 7 days.</p>
1577
+ <pre style="background:#f4f4f4;padding:12px;border-radius:4px;word-break:break-all">${args.token}</pre>
1578
+ </div>`
1579
+ };
1580
+ }
1581
+ function buildResetPasswordEmail(config, args) {
1582
+ const custom = config.email?.templates?.resetPassword?.(args);
1583
+ return {
1584
+ subject: custom?.subject ?? "Reset your password",
1585
+ html: custom?.html ?? `
1586
+ <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
1587
+ <h2>Reset your password</h2>
1588
+ <p>Use the token below to reset your password. It expires in 1 hour.</p>
1589
+ <pre style="background:#f4f4f4;padding:12px;border-radius:4px;word-break:break-all">${args.token}</pre>
1590
+ </div>`
1591
+ };
1592
+ }
1593
+ function buildPasswordChangedEmail(config, args) {
1594
+ const custom = config.email?.templates?.passwordChanged?.(args);
1595
+ return {
1596
+ subject: custom?.subject ?? "Your password has been changed",
1597
+ html: custom?.html ?? `
1598
+ <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
1599
+ <h2>Password changed</h2>
1600
+ <p>The password for <strong>${args.email}</strong> was just changed.</p>
1601
+ <p>If you did not make this change, please contact support immediately.</p>
1602
+ </div>`
1603
+ };
1604
+ }
1605
+
1606
+ // src/controllers/auth.controller.ts
1607
+ var AuthController = class {
1608
+ collection;
1609
+ constructor(collection) {
1610
+ this.collection = collection;
1611
+ }
1612
+ // ---------------------------------------------------------------------------
1613
+ // GET /init
1614
+ // Checks if the first user needs to be created.
1615
+ // ---------------------------------------------------------------------------
1616
+ async init(c) {
1617
+ const db = c.get("config").db;
1618
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1619
+ const result = await db.find({
1620
+ collection: this.collection.slug,
1621
+ limit: 1
1622
+ });
1623
+ return c.json({
1624
+ initialized: result.total > 0
1625
+ });
1626
+ }
1627
+ // ---------------------------------------------------------------------------
1628
+ // POST /first-user
1629
+ // Creates the first user if none exist.
1630
+ // ---------------------------------------------------------------------------
1631
+ async registerFirstUser(c) {
1632
+ const config = c.get("config");
1633
+ const db = config.db;
1634
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1635
+ const check = await db.find({
1636
+ collection: this.collection.slug,
1637
+ limit: 1
1638
+ });
1639
+ if (check.total > 0) {
1640
+ return c.json({ error: true, message: "Initial user already exists." }, 403);
1641
+ }
1642
+ const body = await c.req.json().catch(() => null);
1643
+ if (!body?.email || !body?.password) {
1644
+ return c.json({ error: true, message: "email and password are required." }, 400);
1645
+ }
1646
+ const hashedPassword = await hashPassword(body.password);
1647
+ const user = await db.create({
1648
+ collection: this.collection.slug,
1649
+ data: {
1650
+ ...body,
1651
+ password: hashedPassword,
1652
+ roles: ["admin"]
1653
+ // Default first user to admin
1654
+ }
1655
+ });
1656
+ const token = await signCollectionToken({
1657
+ sub: user.id,
1658
+ email: user.email,
1659
+ collection: this.collection.slug
1660
+ });
1661
+ const { subject, html } = buildWelcomeEmail(config, { email: body.email });
1662
+ sendEmail(config, { to: body.email, subject, html }).catch(
1663
+ (err) => console.error("[dyrected/core] Failed to send welcome email:", err)
1664
+ );
1665
+ const { password: _, ...safeUser } = user;
1666
+ return c.json({ token, user: safeUser });
1667
+ }
1668
+ // ---------------------------------------------------------------------------
1669
+ // POST /login
1670
+ // ---------------------------------------------------------------------------
1671
+ async login(c) {
1672
+ const db = c.get("config").db;
1673
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1674
+ const body = await c.req.json().catch(() => null);
1675
+ if (!body?.email || !body?.password) {
1676
+ return c.json({ error: true, message: "email and password are required." }, 400);
1677
+ }
1678
+ const result = await db.find({
1679
+ collection: this.collection.slug,
1680
+ where: { email: body.email },
1681
+ limit: 1
1682
+ });
1683
+ const user = result.docs[0];
1684
+ if (!user) {
1685
+ return c.json({ error: true, message: "Invalid email or password." }, 401);
1686
+ }
1687
+ const valid = await verifyPassword(body.password, user.password);
1688
+ if (!valid) {
1689
+ return c.json({ error: true, message: "Invalid email or password." }, 401);
1690
+ }
1691
+ const token = await signCollectionToken({
1692
+ sub: user.id,
1693
+ email: user.email,
1694
+ collection: this.collection.slug
1695
+ });
1696
+ const { password: _, ...safeUser } = user;
1697
+ return c.json({ token, user: safeUser });
1698
+ }
1699
+ // ---------------------------------------------------------------------------
1700
+ // POST /logout
1701
+ // Auth collections use stateless JWTs — logout is handled client-side.
1702
+ // This endpoint exists so clients have a consistent API surface.
1703
+ // ---------------------------------------------------------------------------
1704
+ async logout(c) {
1705
+ return c.json({ success: true, message: "Logged out. Discard your token." });
1706
+ }
1707
+ // ---------------------------------------------------------------------------
1708
+ // GET /me
1709
+ // ---------------------------------------------------------------------------
1710
+ async me(c) {
1711
+ const db = c.get("config").db;
1712
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1713
+ const requestUser = c.get("user");
1714
+ if (!requestUser) {
1715
+ return c.json({ error: true, message: "Authentication required." }, 401);
1716
+ }
1717
+ const user = await db.findOne({ collection: this.collection.slug, id: requestUser.sub });
1718
+ if (!user) {
1719
+ return c.json({ error: true, message: "User not found." }, 404);
1720
+ }
1721
+ const { password: _, ...safeUser } = user;
1722
+ return c.json(safeUser);
1723
+ }
1724
+ // ---------------------------------------------------------------------------
1725
+ // POST /refresh-token
1726
+ // ---------------------------------------------------------------------------
1727
+ async refreshToken(c) {
1728
+ const requestUser = c.get("user");
1729
+ if (!requestUser) {
1730
+ return c.json({ error: true, message: "Authentication required." }, 401);
1731
+ }
1732
+ const token = await signCollectionToken({
1733
+ sub: requestUser.sub,
1734
+ email: requestUser.email,
1735
+ collection: this.collection.slug
1736
+ });
1737
+ return c.json({ token });
1738
+ }
1739
+ // ---------------------------------------------------------------------------
1740
+ // POST /forgot-password
1741
+ // Requires config.email to be set. Silently succeeds if email not found
1742
+ // to prevent email enumeration.
1743
+ // ---------------------------------------------------------------------------
1744
+ async forgotPassword(c) {
1745
+ const config = c.get("config");
1746
+ const db = config.db;
1747
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1748
+ const body = await c.req.json().catch(() => null);
1749
+ if (!body?.email) {
1750
+ return c.json({ error: true, message: "email is required." }, 400);
1751
+ }
1752
+ const result = await db.find({
1753
+ collection: this.collection.slug,
1754
+ where: { email: body.email },
1755
+ limit: 1
1756
+ });
1757
+ const user = result.docs[0];
1758
+ if (user) {
1759
+ const resetToken = await signCollectionToken(
1760
+ { sub: user.id, email: user.email, collection: this.collection.slug, purpose: "reset" },
1761
+ "1h"
1762
+ );
1763
+ try {
1764
+ const { subject, html } = buildResetPasswordEmail(config, { token: resetToken });
1765
+ await sendEmail(config, { to: user.email, subject, html });
1766
+ } catch (err) {
1767
+ console.error("[dyrected/core] Failed to send password reset email:", err);
1768
+ }
1769
+ }
1770
+ return c.json({
1771
+ success: true,
1772
+ message: "If an account with that email exists, a reset link has been sent."
1773
+ });
1774
+ }
1775
+ // ---------------------------------------------------------------------------
1776
+ // POST /reset-password
1777
+ // Expects { token: string, password: string } in body.
1778
+ // The token is the reset JWT issued by /forgot-password.
1779
+ // ---------------------------------------------------------------------------
1780
+ async resetPassword(c) {
1781
+ const config = c.get("config");
1782
+ const db = config.db;
1783
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1784
+ const body = await c.req.json().catch(() => null);
1785
+ if (!body?.token || !body?.password) {
1786
+ return c.json({ error: true, message: "token and password are required." }, 400);
1787
+ }
1788
+ let payload;
1789
+ try {
1790
+ payload = await verifyCollectionToken(body.token);
1791
+ } catch {
1792
+ return c.json({ error: true, message: "Reset token is invalid or has expired." }, 400);
1793
+ }
1794
+ if (payload.collection !== this.collection.slug || payload.purpose !== "reset") {
1795
+ return c.json({ error: true, message: "Reset token is invalid or has expired." }, 400);
1796
+ }
1797
+ const hashedPassword = await hashPassword(body.password);
1798
+ await db.update({
1799
+ collection: this.collection.slug,
1800
+ id: payload.sub,
1801
+ data: { password: hashedPassword }
1802
+ });
1803
+ const { subject, html } = buildPasswordChangedEmail(config, { email: payload.email });
1804
+ sendEmail(config, { to: payload.email, subject, html }).catch(
1805
+ (err) => console.error("[dyrected/core] Failed to send password-changed email:", err)
1806
+ );
1807
+ return c.json({ success: true, message: "Password has been reset. You can now log in." });
1808
+ }
1809
+ // ---------------------------------------------------------------------------
1810
+ // POST /invite
1811
+ // Requires auth. Issues a signed invite token and emails it to the invitee.
1812
+ // ---------------------------------------------------------------------------
1813
+ async invite(c) {
1814
+ const config = c.get("config");
1815
+ const db = config.db;
1816
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1817
+ const requestUser = c.get("user");
1818
+ if (!requestUser) {
1819
+ return c.json({ error: true, message: "Authentication required." }, 401);
1820
+ }
1821
+ const body = await c.req.json().catch(() => null);
1822
+ if (!body?.email) {
1823
+ return c.json({ error: true, message: "email is required." }, 400);
1824
+ }
1825
+ const existing = await db.find({
1826
+ collection: this.collection.slug,
1827
+ where: { email: body.email },
1828
+ limit: 1
1829
+ });
1830
+ if (existing.total > 0) {
1831
+ return c.json({ error: true, message: "An account with that email already exists." }, 409);
1832
+ }
1833
+ const inviteToken = await signCollectionToken(
1834
+ { sub: body.email, email: body.email, collection: this.collection.slug, purpose: "invite" },
1835
+ "7d"
1836
+ );
1837
+ try {
1838
+ const { subject, html } = buildInviteEmail(config, {
1839
+ token: inviteToken,
1840
+ invitedByEmail: requestUser.email
1841
+ });
1842
+ await sendEmail(config, { to: body.email, subject, html });
1843
+ } catch (err) {
1844
+ console.error("[dyrected/core] Failed to send invite email:", err);
1845
+ }
1846
+ return c.json({ success: true, message: `Invite sent to ${body.email}.` });
1847
+ }
1848
+ // ---------------------------------------------------------------------------
1849
+ // POST /accept-invite
1850
+ // Public. Validates the invite token and creates the user account.
1851
+ // Body: { token, password, ...extraFields }
1852
+ // ---------------------------------------------------------------------------
1853
+ async acceptInvite(c) {
1854
+ const config = c.get("config");
1855
+ const db = config.db;
1856
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1857
+ const body = await c.req.json().catch(() => null);
1858
+ if (!body?.token || !body?.password) {
1859
+ return c.json({ error: true, message: "token and password are required." }, 400);
1860
+ }
1861
+ let payload;
1862
+ try {
1863
+ payload = await verifyCollectionToken(body.token);
1864
+ } catch {
1865
+ return c.json({ error: true, message: "Invite token is invalid or has expired." }, 400);
1866
+ }
1867
+ if (payload.collection !== this.collection.slug || payload.purpose !== "invite") {
1868
+ return c.json({ error: true, message: "Invite token is invalid or has expired." }, 400);
1869
+ }
1870
+ const inviteeEmail = payload.sub;
1871
+ const existing = await db.find({
1872
+ collection: this.collection.slug,
1873
+ where: { email: inviteeEmail },
1874
+ limit: 1
1875
+ });
1876
+ if (existing.total > 0) {
1877
+ return c.json({ error: true, message: "An account with that email already exists." }, 409);
1878
+ }
1879
+ const { token: _t, password: _p, ...extraFields } = body;
1880
+ const hashedPassword = await hashPassword(body.password);
1881
+ const user = await db.create({
1882
+ collection: this.collection.slug,
1883
+ data: { ...extraFields, email: inviteeEmail, password: hashedPassword }
1884
+ });
1885
+ const sessionToken = await signCollectionToken({
1886
+ sub: user.id,
1887
+ email: inviteeEmail,
1888
+ collection: this.collection.slug
1889
+ });
1890
+ const { subject, html } = buildWelcomeEmail(config, { email: inviteeEmail });
1891
+ sendEmail(config, { to: inviteeEmail, subject, html }).catch(
1892
+ (err) => console.error("[dyrected/core] Failed to send welcome email:", err)
1893
+ );
1894
+ const { password: _, ...safeUser } = user;
1895
+ return c.json({ token: sessionToken, user: safeUser }, 201);
1896
+ }
1897
+ };
1898
+
1899
+ // src/controllers/preview.controller.ts
1900
+ var import_jose2 = require("jose");
1901
+ var import_node_util3 = require("util");
1902
+ var PreviewController = class {
1903
+ getSecret() {
1904
+ const secret = process.env.DYRECTED_JWT_SECRET || process.env.JWT_SECRET || "dyrected-preview-secret-change-me";
1905
+ return new import_node_util3.TextEncoder().encode(secret);
1906
+ }
1907
+ /**
1908
+ * POST /api/preview-token
1909
+ * Generates a short-lived token for previewing unsaved data.
1910
+ */
1911
+ async createToken(c) {
1912
+ const body = await c.req.json().catch(() => null);
1913
+ if (!body?.collectionSlug || !body?.data) {
1914
+ return c.json({ error: true, message: "collectionSlug and data are required." }, 400);
1915
+ }
1916
+ const token = await new import_jose2.SignJWT({
1917
+ collectionSlug: body.collectionSlug,
1918
+ documentId: body.documentId,
1919
+ data: body.data
1920
+ }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime("15m").sign(this.getSecret());
1921
+ const expiresAt = new Date(Date.now() + 15 * 60 * 1e3).toISOString();
1922
+ return c.json({ token, expiresAt });
1923
+ }
1924
+ /**
1925
+ * GET /api/preview-data?token=<jwt>
1926
+ * Returns the data stored in the preview token.
1927
+ */
1928
+ async getData(c) {
1929
+ const token = c.req.query("token");
1930
+ if (!token) {
1931
+ return c.json({ error: true, message: "token query parameter is required." }, 400);
1932
+ }
1933
+ try {
1934
+ const { payload } = await (0, import_jose2.jwtVerify)(token, this.getSecret());
1935
+ return c.json(payload);
1936
+ } catch (err) {
1937
+ return c.json({ error: true, message: "Invalid or expired preview token." }, 401);
1938
+ }
1939
+ }
1940
+ };
1941
+
1942
+ // src/middleware/auth.ts
1943
+ function requireAuth() {
1944
+ return async (c, next) => {
1945
+ const authHeader = c.req.header("Authorization");
1946
+ const token = authHeader?.replace(/^Bearer\s+/i, "");
1947
+ if (!token) {
1948
+ return c.json({ error: true, message: "Authentication required." }, 401);
1949
+ }
1950
+ try {
1951
+ const user = await verifyCollectionToken(token);
1952
+ c.set("user", user);
1953
+ await next();
1954
+ } catch {
1955
+ return c.json({ error: true, message: "Invalid or expired token." }, 401);
1956
+ }
1957
+ };
1958
+ }
1959
+ function optionalAuth() {
1960
+ return async (c, next) => {
1961
+ const authHeader = c.req.header("Authorization");
1962
+ const token = authHeader?.replace(/^Bearer\s+/i, "");
1963
+ if (token) {
1964
+ try {
1965
+ const user = await verifyCollectionToken(token);
1966
+ c.set("user", user);
1967
+ } catch {
1968
+ }
1969
+ }
1970
+ await next();
1971
+ };
1972
+ }
1973
+
1974
+ // src/utils/openapi.ts
1975
+ function generateOpenApi(config) {
1976
+ const spec = {
1977
+ openapi: "3.0.0",
1978
+ info: {
1979
+ title: "Dyrected API",
1980
+ version: "1.0.0",
1981
+ description: "Automatically generated OpenAPI specification for the Dyrected project."
1982
+ },
1983
+ components: {
1984
+ schemas: {},
1985
+ securitySchemes: {
1986
+ ApiKeyAuth: {
1987
+ type: "apiKey",
1988
+ in: "header",
1989
+ name: "x-api-key"
1990
+ }
1991
+ }
1992
+ },
1993
+ paths: {},
1994
+ security: [{ ApiKeyAuth: [] }]
1995
+ };
1996
+ for (const collection of config.collections) {
1997
+ spec.components.schemas[collection.slug] = collectionToSchema(collection);
1998
+ }
1999
+ for (const global of config.globals) {
2000
+ spec.components.schemas[global.slug] = globalToSchema(global);
2001
+ }
2002
+ for (const collection of config.collections) {
2003
+ const slug = collection.slug;
2004
+ const path = `/api/collections/${slug}`;
2005
+ const labels = collection.labels || { singular: slug, plural: `${slug}s` };
2006
+ spec.paths[path] = {
2007
+ get: {
2008
+ tags: ["Collections"],
2009
+ summary: `Find ${labels.plural}`,
2010
+ parameters: [
2011
+ { name: "limit", in: "query", schema: { type: "integer", default: 10 } },
2012
+ { name: "page", in: "query", schema: { type: "integer", default: 1 } },
2013
+ { name: "where", in: "query", schema: { type: "string" }, description: "JSON filter" },
2014
+ { name: "sort", in: "query", schema: { type: "string" }, description: "Sort field (e.g. -createdAt)" }
2015
+ ],
2016
+ responses: {
2017
+ 200: {
2018
+ description: "Success",
2019
+ content: {
2020
+ "application/json": {
2021
+ schema: {
2022
+ type: "object",
2023
+ properties: {
2024
+ docs: { type: "array", items: { $ref: `#/components/schemas/${slug}` } },
2025
+ total: { type: "integer" },
2026
+ limit: { type: "integer" },
2027
+ page: { type: "integer" }
2028
+ }
2029
+ }
2030
+ }
2031
+ }
2032
+ }
2033
+ }
2034
+ },
2035
+ post: {
2036
+ tags: ["Collections"],
2037
+ summary: `Create ${labels.singular}`,
2038
+ requestBody: {
2039
+ required: true,
2040
+ content: {
2041
+ "application/json": {
2042
+ schema: { $ref: `#/components/schemas/${slug}` }
2043
+ }
2044
+ }
2045
+ },
2046
+ responses: {
2047
+ 201: {
2048
+ description: "Created",
2049
+ content: {
2050
+ "application/json": {
2051
+ schema: { $ref: `#/components/schemas/${slug}` }
2052
+ }
2053
+ }
2054
+ }
2055
+ }
2056
+ }
2057
+ };
2058
+ spec.paths[`${path}/{id}`] = {
2059
+ get: {
2060
+ tags: ["Collections"],
2061
+ summary: `Get a single ${labels.singular}`,
2062
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
2063
+ responses: {
2064
+ 200: {
2065
+ description: "Success",
2066
+ content: {
2067
+ "application/json": {
2068
+ schema: { $ref: `#/components/schemas/${slug}` }
2069
+ }
2070
+ }
2071
+ }
2072
+ }
2073
+ },
2074
+ patch: {
2075
+ tags: ["Collections"],
2076
+ summary: `Update ${labels.singular}`,
2077
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
2078
+ requestBody: {
2079
+ required: true,
2080
+ content: {
2081
+ "application/json": {
2082
+ schema: { $ref: `#/components/schemas/${slug}` }
2083
+ }
2084
+ }
2085
+ },
2086
+ responses: {
2087
+ 200: {
2088
+ description: "Updated",
2089
+ content: {
2090
+ "application/json": {
2091
+ schema: { $ref: `#/components/schemas/${slug}` }
2092
+ }
2093
+ }
2094
+ }
2095
+ }
2096
+ },
2097
+ delete: {
2098
+ tags: ["Collections"],
2099
+ summary: `Delete ${labels.singular}`,
2100
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
2101
+ responses: {
2102
+ 204: { description: "Deleted" }
2103
+ }
2104
+ }
2105
+ };
2106
+ }
2107
+ for (const global of config.globals) {
2108
+ const slug = global.slug;
2109
+ const path = `/api/globals/${slug}`;
2110
+ spec.paths[path] = {
2111
+ get: {
2112
+ tags: ["Globals"],
2113
+ summary: `Get ${global.label || slug}`,
2114
+ responses: {
2115
+ 200: {
2116
+ description: "Success",
2117
+ content: {
2118
+ "application/json": {
2119
+ schema: { $ref: `#/components/schemas/${slug}` }
2120
+ }
2121
+ }
2122
+ }
2123
+ }
2124
+ },
2125
+ patch: {
2126
+ tags: ["Globals"],
2127
+ summary: `Update ${global.label || slug}`,
2128
+ requestBody: {
2129
+ required: true,
2130
+ content: {
2131
+ "application/json": {
2132
+ schema: { $ref: `#/components/schemas/${slug}` }
2133
+ }
2134
+ }
2135
+ },
2136
+ responses: {
2137
+ 200: {
2138
+ description: "Updated",
2139
+ content: {
2140
+ "application/json": {
2141
+ schema: { $ref: `#/components/schemas/${slug}` }
2142
+ }
2143
+ }
2144
+ }
2145
+ }
2146
+ }
2147
+ };
2148
+ }
2149
+ if (config.storage) {
2150
+ spec.paths["/api/media"] = {
2151
+ get: {
2152
+ tags: ["Media"],
2153
+ summary: "List Media",
2154
+ responses: {
2155
+ 200: {
2156
+ description: "Success",
2157
+ content: {
2158
+ "application/json": {
2159
+ schema: {
2160
+ type: "object",
2161
+ properties: {
2162
+ docs: { type: "array", items: { type: "object", additionalProperties: true } }
2163
+ }
2164
+ }
2165
+ }
2166
+ }
2167
+ }
2168
+ }
2169
+ },
2170
+ post: {
2171
+ tags: ["Media"],
2172
+ summary: "Upload Media",
2173
+ requestBody: {
2174
+ content: {
2175
+ "multipart/form-data": {
2176
+ schema: {
2177
+ type: "object",
2178
+ properties: {
2179
+ file: { type: "string", format: "binary" }
2180
+ }
2181
+ }
2182
+ }
2183
+ }
2184
+ },
2185
+ responses: {
2186
+ 201: { description: "Uploaded" }
2187
+ }
2188
+ }
2189
+ };
2190
+ }
2191
+ return spec;
2192
+ }
2193
+ function collectionToSchema(collection) {
2194
+ const { properties, required } = fieldsToProperties(collection.fields);
2195
+ return {
2196
+ type: "object",
2197
+ properties: {
2198
+ id: { type: "string" },
2199
+ createdAt: { type: "string", format: "date-time" },
2200
+ updatedAt: { type: "string", format: "date-time" },
2201
+ ...properties
2202
+ },
2203
+ required: ["id", ...required]
2204
+ };
2205
+ }
2206
+ function globalToSchema(global) {
2207
+ const { properties, required } = fieldsToProperties(global.fields);
2208
+ return {
2209
+ type: "object",
2210
+ properties,
2211
+ required
2212
+ };
2213
+ }
2214
+ function fieldsToProperties(fields) {
2215
+ const props = {};
2216
+ const required = [];
2217
+ for (const field of fields) {
2218
+ props[field.name] = fieldToSchema(field);
2219
+ if (field.required) {
2220
+ required.push(field.name);
2221
+ }
2222
+ }
2223
+ return { properties: props, required };
2224
+ }
2225
+ function fieldToSchema(field) {
2226
+ let schema = {};
2227
+ switch (field.type) {
2228
+ case "text":
2229
+ case "textarea":
2230
+ case "email":
2231
+ case "url":
2232
+ schema = { type: "string" };
2233
+ break;
2234
+ case "number":
2235
+ schema = { type: "number" };
2236
+ break;
2237
+ case "boolean":
2238
+ schema = { type: "boolean" };
2239
+ break;
2240
+ case "date":
2241
+ schema = { type: "string", format: "date-time" };
2242
+ break;
2243
+ case "select":
2244
+ schema = { type: "string", enum: field.options?.map((o) => typeof o === "string" ? o : o.value) };
2245
+ break;
2246
+ case "multiSelect":
2247
+ schema = {
2248
+ type: "array",
2249
+ items: { type: "string", enum: field.options?.map((o) => typeof o === "string" ? o : o.value) }
2250
+ };
2251
+ break;
2252
+ case "relationship":
2253
+ schema = { type: "string", description: `ID of a ${field.relationTo} record` };
2254
+ break;
2255
+ case "object": {
2256
+ const { properties, required } = fieldsToProperties(field.fields || []);
2257
+ schema = { type: "object", properties, required };
2258
+ break;
2259
+ }
2260
+ case "array": {
2261
+ const { properties, required } = fieldsToProperties(field.fields || []);
2262
+ schema = { type: "array", items: { type: "object", properties, required } };
2263
+ break;
2264
+ }
2265
+ case "json":
2266
+ case "richText":
2267
+ schema = { type: "object", additionalProperties: true };
2268
+ break;
2269
+ case "blocks":
2270
+ schema = {
2271
+ type: "array",
2272
+ items: {
2273
+ oneOf: field.blocks?.map((block) => {
2274
+ const { properties, required } = fieldsToProperties(block.fields);
2275
+ return {
2276
+ type: "object",
2277
+ properties: {
2278
+ blockType: { type: "string", enum: [block.slug] },
2279
+ ...properties
2280
+ },
2281
+ required: ["blockType", ...required]
2282
+ };
2283
+ })
2284
+ }
2285
+ };
2286
+ break;
2287
+ default:
2288
+ schema = { type: "string" };
2289
+ }
2290
+ if (field.label) schema.description = field.label;
2291
+ return schema;
2292
+ }
2293
+
2294
+ // src/utils/swagger.ts
2295
+ function getSwaggerHtml(specUrl = "/api/openapi.json") {
2296
+ return `
2297
+ <!DOCTYPE html>
2298
+ <html lang="en">
2299
+ <head>
2300
+ <meta charset="utf-8" />
2301
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2302
+ <meta name="description" content="SwaggerUI" />
2303
+ <title>Dyrected API Documentation</title>
2304
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
2305
+ </head>
2306
+ <body>
2307
+ <div id="swagger-ui"></div>
2308
+ <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" charset="UTF-8"></script>
2309
+ <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js" charset="UTF-8"></script>
2310
+ <script>
2311
+ window.onload = () => {
2312
+ // Forward the apikey query param when loading the spec and making API calls
2313
+ const params = new URLSearchParams(window.location.search);
2314
+ const apiKey = params.get('apikey');
2315
+ const specUrlWithKey = apiKey ? '${specUrl}?apikey=' + encodeURIComponent(apiKey) : '${specUrl}';
2316
+
2317
+ window.ui = SwaggerUIBundle({
2318
+ url: specUrlWithKey,
2319
+ dom_id: '#swagger-ui',
2320
+ presets: [
2321
+ SwaggerUIBundle.presets.apis,
2322
+ SwaggerUIStandalonePreset
2323
+ ],
2324
+ layout: "BaseLayout",
2325
+ deepLinking: true,
2326
+ showExtensions: true,
2327
+ showCommonExtensions: true,
2328
+ // Inject x-api-key header on every request made from the Swagger UI
2329
+ requestInterceptor: (request) => {
2330
+ if (apiKey) {
2331
+ request.headers['x-api-key'] = apiKey;
2332
+ }
2333
+ return request;
2334
+ }
2335
+ });
2336
+ };
2337
+ </script>
2338
+ </body>
2339
+ </html>
2340
+ `;
2341
+ }
2342
+
2343
+ // src/auth/jexl.ts
2344
+ var import_jexl = __toESM(require("jexl"), 1);
2345
+ async function evaluateAccess(expression, context) {
2346
+ if (expression === void 0 || expression === null) return false;
2347
+ if (typeof expression === "boolean") return expression;
2348
+ try {
2349
+ const result = await import_jexl.default.eval(expression, context);
2350
+ return !!result;
2351
+ } catch (err) {
2352
+ console.error("[dyrected/core] Jexl evaluation failed:", err);
2353
+ return false;
2354
+ }
2355
+ }
2356
+
2357
+ // src/router.ts
2358
+ function accessGate(target, action) {
2359
+ return async (c, next) => {
2360
+ const user = c.get("user");
2361
+ const accessExpr = target.access?.[action];
2362
+ if (accessExpr === void 0 || accessExpr === null) {
2363
+ return await next();
2364
+ }
2365
+ const accessArgs = { user, req: c.req, doc: null };
2366
+ const allowed = await evaluateAccess(accessExpr, accessArgs);
2367
+ if (!allowed) {
2368
+ return c.json({ error: true, message: `Access denied: ${action} on ${target.slug}` }, 403);
2369
+ }
2370
+ await next();
2371
+ };
2372
+ }
2373
+ function registerRoutes(app, config) {
2374
+ app.get("/api/schemas", optionalAuth(), async (c) => {
2375
+ const siteId = c.req.header("X-Site-Id");
2376
+ let collections = [...config.collections];
2377
+ let globals = [...config.globals];
2378
+ if (siteId && config.onSchemaFetch) {
2379
+ const dynamic = await config.onSchemaFetch(siteId);
2380
+ if (dynamic.collections) collections = [...collections, ...dynamic.collections];
2381
+ if (dynamic.globals) globals = [...globals, ...dynamic.globals];
2382
+ if (dynamic.admin) {
2383
+ config.admin = { ...config.admin, ...dynamic.admin };
2384
+ }
2385
+ }
2386
+ const user = c.get("user");
2387
+ const accessArgs = { user, req: c.req, doc: null };
2388
+ const resolveAccess = async (access) => {
2389
+ if (access === void 0 || access === null) return true;
2390
+ if (typeof access === "function") {
2391
+ try {
2392
+ const result = await access(accessArgs);
2393
+ return typeof result === "boolean" ? result : !!result;
2394
+ } catch (err) {
2395
+ console.error("[dyrected/core] Functional access check failed:", err);
2396
+ return false;
2397
+ }
2398
+ }
2399
+ if (typeof access === "string" || typeof access === "boolean") {
2400
+ return evaluateAccess(access, accessArgs);
2401
+ }
2402
+ return true;
2403
+ };
2404
+ const filteredCollections = await Promise.all(collections.filter((col) => !siteId || col.shared || !col.siteId || col.siteId === siteId).map(async (col) => ({
2405
+ slug: col.slug,
2406
+ labels: col.labels,
2407
+ access: {
2408
+ read: await resolveAccess(col.access?.read),
2409
+ create: await resolveAccess(col.access?.create),
2410
+ update: await resolveAccess(col.access?.update),
2411
+ delete: await resolveAccess(col.access?.delete)
2412
+ },
2413
+ fields: await Promise.all(col.fields.map(async (f) => ({
2414
+ name: f.name,
2415
+ type: f.type,
2416
+ label: f.label,
2417
+ required: f.required,
2418
+ defaultValue: f.defaultValue,
2419
+ options: f.options,
2420
+ relationTo: f.relationTo,
2421
+ hasMany: f.hasMany,
2422
+ fields: f.fields,
2423
+ blocks: f.blocks,
2424
+ admin: f.admin,
2425
+ access: {
2426
+ read: await resolveAccess(f.access?.read),
2427
+ update: await resolveAccess(f.access?.update)
2428
+ }
2429
+ }))),
2430
+ upload: !!col.upload,
2431
+ auth: !!col.auth,
2432
+ admin: col.admin
2433
+ })));
2434
+ const filteredGlobals = await Promise.all(globals.filter((glb) => !siteId || glb.shared || !glb.siteId || glb.siteId === siteId).map(async (glb) => ({
2435
+ slug: glb.slug,
2436
+ label: glb.label,
2437
+ access: {
2438
+ read: await resolveAccess(glb.access?.read),
2439
+ update: await resolveAccess(glb.access?.update)
2440
+ },
2441
+ fields: await Promise.all(glb.fields.map(async (f) => ({
2442
+ name: f.name,
2443
+ type: f.type,
2444
+ label: f.label,
2445
+ required: f.required,
2446
+ defaultValue: f.defaultValue,
2447
+ options: f.options,
2448
+ relationTo: f.relationTo,
2449
+ hasMany: f.hasMany,
2450
+ fields: f.fields,
2451
+ blocks: f.blocks,
2452
+ admin: f.admin,
2453
+ access: {
2454
+ read: await resolveAccess(f.access?.read),
2455
+ update: await resolveAccess(f.access?.update)
2456
+ }
2457
+ }))),
2458
+ admin: glb.admin
2459
+ })));
2460
+ return c.json({
2461
+ collections: filteredCollections,
2462
+ globals: filteredGlobals,
2463
+ admin: config.admin || {}
2464
+ });
2465
+ });
2466
+ app.get("/api/openapi.json", (c) => {
2467
+ return c.json(generateOpenApi(config));
2468
+ });
2469
+ app.get("/api/docs", (c) => {
2470
+ return c.html(getSwaggerHtml());
2471
+ });
2472
+ app.get("/api/media/:filename{.+$}", async (c) => {
2473
+ const mediaController = new MediaController("media");
2474
+ return mediaController.serve(c);
2475
+ });
2476
+ app.get("/media/:filename{.+$}", async (c) => {
2477
+ const mediaController = new MediaController("media");
2478
+ return mediaController.serve(c);
2479
+ });
2480
+ if (config.storage) {
2481
+ const uploadCollections = config.collections.filter((c) => c.upload);
2482
+ for (const col of uploadCollections) {
2483
+ const mediaController = new MediaController(col.slug);
2484
+ const prefix = `/api/collections/${col.slug}`;
2485
+ app.get(`${prefix}/media`, accessGate(col, "read"), (c) => mediaController.find(c));
2486
+ app.get(`${prefix}/media/:filename{.+$}`, (c) => mediaController.serve(c));
2487
+ app.post(`${prefix}/media`, accessGate(col, "create"), (c) => mediaController.upload(c));
2488
+ app.delete(`${prefix}/media/:id`, accessGate(col, "delete"), (c) => mediaController.delete(c));
2489
+ }
2490
+ }
2491
+ for (const collection of config.collections) {
2492
+ if (!collection.auth) continue;
2493
+ const path = `/api/collections/${collection.slug}`;
2494
+ const authController = new AuthController(collection);
2495
+ app.post(`${path}/login`, (c) => authController.login(c));
2496
+ app.post(`${path}/logout`, (c) => authController.logout(c));
2497
+ app.get(`${path}/init`, (c) => authController.init(c));
2498
+ app.post(`${path}/first-user`, (c) => authController.registerFirstUser(c));
2499
+ app.get(`${path}/me`, requireAuth(), (c) => authController.me(c));
2500
+ app.post(`${path}/refresh-token`, requireAuth(), (c) => authController.refreshToken(c));
2501
+ app.post(`${path}/forgot-password`, (c) => authController.forgotPassword(c));
2502
+ app.post(`${path}/reset-password`, (c) => authController.resetPassword(c));
2503
+ app.post(`${path}/invite`, requireAuth(), (c) => authController.invite(c));
2504
+ app.post(`${path}/accept-invite`, (c) => authController.acceptInvite(c));
2505
+ }
2506
+ for (const collection of config.collections) {
2507
+ const path = `/api/collections/${collection.slug}`;
2508
+ const controller = new CollectionController(collection);
2509
+ app.get(path, accessGate(collection, "read"), (c) => controller.find(c));
2510
+ app.post(path, accessGate(collection, "create"), (c) => controller.create(c));
2511
+ app.post(`${path}/media`, accessGate(collection, "create"), (c) => controller.create(c));
2512
+ app.get(`${path}/:id`, accessGate(collection, "read"), (c) => controller.findOne(c));
2513
+ app.patch(`${path}/:id`, accessGate(collection, "update"), (c) => controller.update(c));
2514
+ app.delete(`${path}/:id`, accessGate(collection, "delete"), (c) => controller.delete(c));
2515
+ app.post(`${path}/seed`, (c) => controller.seed(c));
2516
+ }
2517
+ for (const global of config.globals) {
2518
+ const path = `/api/globals/${global.slug}`;
2519
+ const controller = new GlobalController(global);
2520
+ app.get(path, accessGate(global, "read"), (c) => controller.get(c));
2521
+ app.patch(path, accessGate(global, "update"), (c) => controller.update(c));
2522
+ app.post(`${path}/seed`, (c) => controller.seed(c));
2523
+ }
2524
+ const previewController = new PreviewController();
2525
+ app.post("/api/preview-token", requireAuth(), (c) => previewController.createToken(c));
2526
+ app.get("/api/preview-data", (c) => previewController.getData(c));
2527
+ app.all("/api/collections/:slug/:id?", async (c) => {
2528
+ const slug = c.req.param("slug");
2529
+ const id = c.req.param("id");
2530
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
2531
+ const config2 = c.get("config");
2532
+ if (config2.collections.some((col) => col.slug === slug)) {
2533
+ return c.json({ message: "Method Not Allowed" }, 405);
2534
+ }
2535
+ if (config2.onSchemaFetch && siteId) {
2536
+ const dynamic = await config2.onSchemaFetch(siteId);
2537
+ let collection = dynamic.collections?.find((col) => col.slug === slug);
2538
+ if (!collection && slug === "media") {
2539
+ collection = {
2540
+ slug: "media",
2541
+ labels: { singular: "Media", plural: "Media" },
2542
+ upload: true,
2543
+ fields: []
2544
+ };
2545
+ }
2546
+ if (collection) {
2547
+ if (collection.auth && id) {
2548
+ const authController = new AuthController(collection);
2549
+ const method2 = c.req.method;
2550
+ if (method2 === "POST" && id === "login") return authController.login(c);
2551
+ if (method2 === "POST" && id === "logout") return authController.logout(c);
2552
+ if (method2 === "GET" && id === "me") return authController.me(c);
2553
+ if (method2 === "POST" && id === "refresh-token") return authController.refreshToken(c);
2554
+ if (method2 === "POST" && id === "forgot-password") return authController.forgotPassword(c);
2555
+ if (method2 === "POST" && id === "reset-password") return authController.resetPassword(c);
2556
+ }
2557
+ const controller = new CollectionController(collection);
2558
+ const method = c.req.method;
2559
+ if (id) {
2560
+ if (method === "GET") return controller.findOne(c);
2561
+ if (method === "PATCH") return controller.update(c);
2562
+ if (method === "DELETE") return controller.delete(c);
2563
+ if (method === "POST" && id === "media") return controller.create(c);
2564
+ } else {
2565
+ if (method === "GET") return controller.find(c);
2566
+ if (method === "POST") return controller.create(c);
2567
+ }
2568
+ }
2569
+ }
2570
+ return c.json({ message: `Collection "${slug}" not found` }, 404);
2571
+ });
2572
+ app.all("/api/globals/:slug", async (c) => {
2573
+ const slug = c.req.param("slug");
2574
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
2575
+ const config2 = c.get("config");
2576
+ if (config2.globals.some((glb) => glb.slug === slug)) {
2577
+ return c.json({ message: "Method Not Allowed" }, 405);
2578
+ }
2579
+ if (config2.onSchemaFetch && siteId) {
2580
+ const dynamic = await config2.onSchemaFetch(siteId);
2581
+ const global = dynamic.globals?.find((glb) => glb.slug === slug);
2582
+ if (global) {
2583
+ const controller = new GlobalController(global);
2584
+ if (c.req.method === "GET") return controller.get(c);
2585
+ if (c.req.method === "PATCH") return controller.update(c);
2586
+ }
2587
+ }
2588
+ return c.json({ message: `Global "${slug}" not found` }, 404);
2589
+ });
2590
+ }
2591
+
2592
+ // src/app.ts
2593
+ async function createDyrectedApp(rawConfig) {
2594
+ const config = normalizeConfig(rawConfig);
2595
+ const app = new import_hono.Hono();
2596
+ if (config.db?.sync) {
2597
+ await config.db.sync(config.collections, config.globals);
2598
+ }
2599
+ app.use("*", (0, import_request_id.requestId)());
2600
+ app.use("*", optionalAuth());
2601
+ app.use("*", async (c, next) => {
2602
+ const start = Date.now();
2603
+ await next();
2604
+ const ms = Date.now() - start;
2605
+ console.log(`[dyrected/api] ${c.req.method} ${c.req.path} ${c.res.status} - ${ms}ms`);
2606
+ });
2607
+ app.use("*", (0, import_cors.cors)());
2608
+ app.use("*", async (c, next) => {
2609
+ c.set("config", config);
2610
+ if (!c.get("siteId")) {
2611
+ c.set("siteId", "default");
2612
+ }
2613
+ await next();
2614
+ });
2615
+ app.get("/health", (c) => c.json({ status: "ok", version: "0.0.1" }));
2616
+ app.get("/routes", (c) => {
2617
+ const routes = app.routes.map((r) => ({ method: r.method, path: r.path }));
2618
+ return c.json({ routes });
2619
+ });
2620
+ app.onError((err, c) => {
2621
+ console.error(`[dyrected/core] Uncaught Error:`, err);
2622
+ return c.json({
2623
+ message: err.message || "Internal Server Error",
2624
+ stack: process.env.NODE_ENV === "development" ? err.stack : void 0
2625
+ }, 500);
2626
+ });
2627
+ registerRoutes(app, config);
2628
+ return app;
2629
+ }
2630
+
543
2631
  // src/index.ts
544
2632
  function defineCollection(config) {
545
2633
  return config;
@@ -552,9 +2640,11 @@ function defineConfig(config) {
552
2640
  }
553
2641
  // Annotate the CommonJS export names for ESM import in node:
554
2642
  0 && (module.exports = {
2643
+ createDyrectedApp,
555
2644
  defineCollection,
556
2645
  defineConfig,
557
2646
  defineGlobal,
558
2647
  generateAIPrompt,
2648
+ generateFreshSetupPrompt,
559
2649
  normalizeConfig
560
2650
  });