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