@dyrected/core 2.0.0 → 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/app-KApbf4XL.d.cts +362 -0
- package/dist/app-KApbf4XL.d.ts +362 -0
- package/dist/chunk-22JTWD74.js +1784 -0
- package/dist/index.cjs +2169 -79
- package/dist/index.d.cts +7 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +448 -82
- package/dist/server.d.cts +4 -22
- package/dist/server.d.ts +4 -22
- package/dist/server.js +8 -1715
- package/package.json +2 -1
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(
|
|
45
|
-
|
|
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
|
|
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,
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
\
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
|
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 {
|
|
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',
|
|
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',
|
|
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',
|
|
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',
|
|
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:
|
|
245
|
-
db: new
|
|
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/
|
|
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="/
|
|
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/
|
|
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="/
|
|
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/
|
|
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/
|
|
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
|
-
|
|
842
|
+
missionText,
|
|
468
843
|
buildEnvironmentSection(frameworkLabel, isSelfHosted, config),
|
|
469
|
-
buildDiagnosticSection(
|
|
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
|
});
|