@dyrected/core 0.0.1 → 1.0.1
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/LICENSE.md +50 -0
- package/README.md +35 -1
- package/dist/chunk-FURYJ254.mjs +33 -0
- package/dist/chunk-GM4WW6IE.js +66 -0
- package/dist/index-D38_E0gf.d.cts +336 -0
- package/dist/index-D38_E0gf.d.ts +336 -0
- package/dist/index-RylhgOwj.d.cts +340 -0
- package/dist/index-RylhgOwj.d.ts +340 -0
- package/dist/index.cjs +413 -0
- package/dist/index.d.cts +33 -0
- package/dist/index.d.mts +163 -11
- package/dist/index.d.ts +15 -247
- package/dist/index.js +298 -366
- package/dist/index.mjs +1692 -77
- package/dist/server.cjs +1827 -0
- package/dist/server.d.cts +243 -0
- package/dist/server.d.ts +243 -0
- package/dist/server.js +1725 -0
- package/dist/token-7QG5DBME.mjs +10 -0
- package/package.json +32 -3
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// src/app.ts
|
|
2
2
|
import { Hono } from "hono";
|
|
3
|
-
import { logger } from "hono/logger";
|
|
4
3
|
import { cors } from "hono/cors";
|
|
5
4
|
import { requestId } from "hono/request-id";
|
|
6
5
|
|
|
@@ -26,13 +25,13 @@ var PopulationService = class {
|
|
|
26
25
|
const populatedDoc = { ...data };
|
|
27
26
|
for (const field of fields) {
|
|
28
27
|
const value = populatedDoc[field.name];
|
|
29
|
-
if (field.type === "relationship" && field.
|
|
30
|
-
const relatedCollection = this.collections.find((c) => c.slug === field.
|
|
28
|
+
if (field.type === "relationship" && field.relationTo && value) {
|
|
29
|
+
const relatedCollection = this.collections.find((c) => c.slug === field.relationTo);
|
|
31
30
|
if (!relatedCollection) continue;
|
|
32
31
|
if (Array.isArray(value)) {
|
|
33
32
|
populatedDoc[field.name] = await Promise.all(
|
|
34
33
|
value.map(async (id) => {
|
|
35
|
-
const doc = await this.db.findOne({ collection: field.
|
|
34
|
+
const doc = await this.db.findOne({ collection: field.relationTo, id });
|
|
36
35
|
if (!doc) return id;
|
|
37
36
|
return this.populate({
|
|
38
37
|
data: doc,
|
|
@@ -43,7 +42,7 @@ var PopulationService = class {
|
|
|
43
42
|
})
|
|
44
43
|
);
|
|
45
44
|
} else if (typeof value === "string") {
|
|
46
|
-
const doc = await this.db.findOne({ collection: field.
|
|
45
|
+
const doc = await this.db.findOne({ collection: field.relationTo, id: value });
|
|
47
46
|
if (doc) {
|
|
48
47
|
populatedDoc[field.name] = await this.populate({
|
|
49
48
|
data: doc,
|
|
@@ -63,6 +62,20 @@ var PopulationService = class {
|
|
|
63
62
|
maxDepth
|
|
64
63
|
});
|
|
65
64
|
}
|
|
65
|
+
if (field.type === "blocks" && field.blocks && Array.isArray(value)) {
|
|
66
|
+
populatedDoc[field.name] = await Promise.all(
|
|
67
|
+
value.map(async (blockData) => {
|
|
68
|
+
const blockConfig = field.blocks.find((b) => b.slug === blockData.blockType);
|
|
69
|
+
if (!blockConfig) return blockData;
|
|
70
|
+
return this.populate({
|
|
71
|
+
data: blockData,
|
|
72
|
+
fields: blockConfig.fields,
|
|
73
|
+
currentDepth,
|
|
74
|
+
maxDepth
|
|
75
|
+
});
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
}
|
|
66
79
|
}
|
|
67
80
|
return populatedDoc;
|
|
68
81
|
}
|
|
@@ -84,6 +97,36 @@ var PopulationService = class {
|
|
|
84
97
|
}
|
|
85
98
|
};
|
|
86
99
|
|
|
100
|
+
// src/services/defaults.service.ts
|
|
101
|
+
var DefaultsService = class {
|
|
102
|
+
/**
|
|
103
|
+
* Recursively apply default values to a data object based on field definitions.
|
|
104
|
+
*/
|
|
105
|
+
static apply(fields, data = {}) {
|
|
106
|
+
const result = { ...data || {} };
|
|
107
|
+
fields.forEach((field) => {
|
|
108
|
+
const value = result[field.name];
|
|
109
|
+
if (value === void 0 || value === null) {
|
|
110
|
+
if (field.defaultValue !== void 0) {
|
|
111
|
+
result[field.name] = field.defaultValue;
|
|
112
|
+
} else {
|
|
113
|
+
if (field.type === "boolean") result[field.name] = false;
|
|
114
|
+
else if (field.type === "array") result[field.name] = [];
|
|
115
|
+
else if (field.type === "multiSelect") result[field.name] = [];
|
|
116
|
+
else if (field.type === "object") {
|
|
117
|
+
result[field.name] = this.apply(field.fields || [], {});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} else if (field.type === "object" && field.fields) {
|
|
121
|
+
result[field.name] = this.apply(field.fields, value);
|
|
122
|
+
} else if (field.type === "array" && field.fields && Array.isArray(value)) {
|
|
123
|
+
result[field.name] = value.map((item) => this.apply(field.fields, item));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
87
130
|
// src/controllers/collection.controller.ts
|
|
88
131
|
var CollectionController = class {
|
|
89
132
|
constructor(collection) {
|
|
@@ -91,50 +134,104 @@ var CollectionController = class {
|
|
|
91
134
|
}
|
|
92
135
|
collection;
|
|
93
136
|
async find(c) {
|
|
94
|
-
const
|
|
95
|
-
const db =
|
|
137
|
+
const config2 = c.get("config");
|
|
138
|
+
const db = config2.db;
|
|
139
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
96
140
|
const limit = Number(c.req.query("limit")) || 10;
|
|
97
141
|
const page = Number(c.req.query("page")) || 1;
|
|
98
|
-
const depth = Number(c.req.query("depth"))
|
|
142
|
+
const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
|
|
143
|
+
const sort = c.req.query("sort") || void 0;
|
|
144
|
+
let where = void 0;
|
|
145
|
+
const whereRaw = c.req.query("where");
|
|
146
|
+
if (whereRaw) {
|
|
147
|
+
try {
|
|
148
|
+
where = JSON.parse(decodeURIComponent(whereRaw));
|
|
149
|
+
} catch {
|
|
150
|
+
}
|
|
151
|
+
}
|
|
99
152
|
let result = await db.find({
|
|
100
153
|
collection: this.collection.slug,
|
|
101
154
|
limit,
|
|
102
|
-
page
|
|
155
|
+
page,
|
|
156
|
+
sort,
|
|
157
|
+
where
|
|
103
158
|
});
|
|
159
|
+
result.docs = result.docs.map((doc) => DefaultsService.apply(this.collection.fields, doc));
|
|
104
160
|
if (depth > 0) {
|
|
105
|
-
const populationService = new PopulationService(db,
|
|
161
|
+
const populationService = new PopulationService(db, config2.collections);
|
|
106
162
|
result = await populationService.populateResult(result, this.collection.fields, depth);
|
|
107
163
|
}
|
|
108
164
|
return c.json(result);
|
|
109
165
|
}
|
|
110
166
|
async findOne(c) {
|
|
111
|
-
const
|
|
112
|
-
const db =
|
|
167
|
+
const config2 = c.get("config");
|
|
168
|
+
const db = config2.db;
|
|
169
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
113
170
|
const id = c.req.param("id");
|
|
114
|
-
const depth = Number(c.req.query("depth"))
|
|
171
|
+
const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
|
|
115
172
|
if (!id) return c.json({ message: "Missing ID" }, 400);
|
|
116
173
|
const doc = await db.findOne({ collection: this.collection.slug, id });
|
|
117
174
|
if (!doc) return c.json({ message: "Not Found" }, 404);
|
|
118
|
-
|
|
119
|
-
|
|
175
|
+
const docWithDefaults = DefaultsService.apply(this.collection.fields, doc);
|
|
176
|
+
if (depth > 0 && docWithDefaults) {
|
|
177
|
+
const populationService = new PopulationService(db, config2.collections);
|
|
120
178
|
const populatedDoc = await populationService.populate({
|
|
121
|
-
data:
|
|
179
|
+
data: docWithDefaults,
|
|
122
180
|
fields: this.collection.fields,
|
|
123
181
|
currentDepth: 0,
|
|
124
182
|
maxDepth: depth
|
|
125
183
|
});
|
|
126
184
|
return c.json(populatedDoc);
|
|
127
185
|
}
|
|
128
|
-
return c.json(
|
|
186
|
+
return c.json(docWithDefaults);
|
|
129
187
|
}
|
|
130
188
|
async create(c) {
|
|
131
|
-
const
|
|
189
|
+
const config2 = c.get("config");
|
|
190
|
+
const db = config2.db;
|
|
191
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
192
|
+
const contentType = c.req.header("Content-Type") || "";
|
|
193
|
+
if (contentType.toLowerCase().includes("multipart/form-data")) {
|
|
194
|
+
return this.upload(c);
|
|
195
|
+
}
|
|
132
196
|
const body = await c.req.json();
|
|
133
197
|
const doc = await db.create({ collection: this.collection.slug, data: body });
|
|
134
198
|
return c.json(doc, 201);
|
|
135
199
|
}
|
|
200
|
+
async upload(c) {
|
|
201
|
+
const config2 = c.get("config");
|
|
202
|
+
const storage = config2.storage;
|
|
203
|
+
if (!storage) return c.json({ message: "Storage not configured" }, 500);
|
|
204
|
+
const formData = await c.req.formData();
|
|
205
|
+
const file = formData.get("file");
|
|
206
|
+
if (!file) return c.json({ message: "No file uploaded" }, 400);
|
|
207
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
208
|
+
const siteId = c.get("siteId");
|
|
209
|
+
const workspaceId = c.get("workspaceId");
|
|
210
|
+
const prefix = workspaceId ? `${workspaceId}/${siteId}` : siteId;
|
|
211
|
+
const fileData = await storage.upload({
|
|
212
|
+
filename: file.name,
|
|
213
|
+
buffer,
|
|
214
|
+
mimeType: file.type,
|
|
215
|
+
prefix
|
|
216
|
+
});
|
|
217
|
+
const otherData = {};
|
|
218
|
+
formData.forEach((value, key) => {
|
|
219
|
+
if (key !== "file" && typeof value === "string") {
|
|
220
|
+
otherData[key] = value;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
const doc = await config2.db.create({
|
|
224
|
+
collection: this.collection.slug,
|
|
225
|
+
data: {
|
|
226
|
+
...otherData,
|
|
227
|
+
...fileData
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
return c.json(doc, 201);
|
|
231
|
+
}
|
|
136
232
|
async update(c) {
|
|
137
233
|
const db = c.get("config").db;
|
|
234
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
138
235
|
const id = c.req.param("id");
|
|
139
236
|
if (!id) return c.json({ message: "Missing ID" }, 400);
|
|
140
237
|
const body = await c.req.json();
|
|
@@ -143,11 +240,33 @@ var CollectionController = class {
|
|
|
143
240
|
}
|
|
144
241
|
async delete(c) {
|
|
145
242
|
const db = c.get("config").db;
|
|
243
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
146
244
|
const id = c.req.param("id");
|
|
147
245
|
if (!id) return c.json({ message: "Missing ID" }, 400);
|
|
148
246
|
await db.delete({ collection: this.collection.slug, id });
|
|
149
247
|
return c.json({ message: "Deleted" });
|
|
150
248
|
}
|
|
249
|
+
async seed(c) {
|
|
250
|
+
const config2 = c.get("config");
|
|
251
|
+
const db = config2.db;
|
|
252
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
253
|
+
const body = await c.req.json();
|
|
254
|
+
const initialData = body.data;
|
|
255
|
+
if (!initialData || !Array.isArray(initialData)) {
|
|
256
|
+
return c.json({ message: "Invalid initial data" }, 400);
|
|
257
|
+
}
|
|
258
|
+
const result = await db.find({ collection: this.collection.slug, limit: 1 });
|
|
259
|
+
if (result.total > 0) {
|
|
260
|
+
return c.json({ message: "Collection is not empty, skipping seed" });
|
|
261
|
+
}
|
|
262
|
+
console.log(`[dyrected/core] Auto-seeding collection: ${this.collection.slug}`);
|
|
263
|
+
const createdDocs = [];
|
|
264
|
+
for (const data of initialData) {
|
|
265
|
+
const doc = await db.create({ collection: this.collection.slug, data });
|
|
266
|
+
createdDocs.push(doc);
|
|
267
|
+
}
|
|
268
|
+
return c.json({ message: "Seed successful", count: createdDocs.length }, 201);
|
|
269
|
+
}
|
|
151
270
|
};
|
|
152
271
|
|
|
153
272
|
// src/controllers/global.controller.ts
|
|
@@ -157,139 +276,1375 @@ var GlobalController = class {
|
|
|
157
276
|
}
|
|
158
277
|
global;
|
|
159
278
|
async get(c) {
|
|
160
|
-
const
|
|
161
|
-
const db =
|
|
162
|
-
|
|
279
|
+
const config2 = c.get("config");
|
|
280
|
+
const db = config2.db;
|
|
281
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
282
|
+
const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
|
|
163
283
|
const data = await db.getGlobal({ slug: this.global.slug });
|
|
164
|
-
|
|
165
|
-
|
|
284
|
+
const dataWithDefaults = DefaultsService.apply(this.global.fields, data);
|
|
285
|
+
if (depth > 0 && dataWithDefaults) {
|
|
286
|
+
const populationService = new PopulationService(db, config2.collections);
|
|
166
287
|
const populatedData = await populationService.populate({
|
|
167
|
-
data,
|
|
288
|
+
data: dataWithDefaults,
|
|
168
289
|
fields: this.global.fields,
|
|
169
|
-
currentDepth:
|
|
290
|
+
currentDepth: 1,
|
|
170
291
|
maxDepth: depth
|
|
171
292
|
});
|
|
172
293
|
return c.json(populatedData);
|
|
173
294
|
}
|
|
174
|
-
return c.json(
|
|
295
|
+
return c.json(dataWithDefaults);
|
|
175
296
|
}
|
|
176
297
|
async update(c) {
|
|
177
298
|
const db = c.get("config").db;
|
|
299
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
178
300
|
const body = await c.req.json();
|
|
179
301
|
const data = await db.updateGlobal({ slug: this.global.slug, data: body });
|
|
180
302
|
return c.json(data);
|
|
181
303
|
}
|
|
304
|
+
async seed(c) {
|
|
305
|
+
const config2 = c.get("config");
|
|
306
|
+
const db = config2.db;
|
|
307
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
308
|
+
const body = await c.req.json();
|
|
309
|
+
const initialData = body.data;
|
|
310
|
+
if (!initialData) {
|
|
311
|
+
return c.json({ message: "Invalid initial data" }, 400);
|
|
312
|
+
}
|
|
313
|
+
const existing = await db.getGlobal({ slug: this.global.slug });
|
|
314
|
+
if (existing && Object.keys(existing).length > 0) {
|
|
315
|
+
return c.json({ message: "Global is not empty, skipping seed" });
|
|
316
|
+
}
|
|
317
|
+
console.log(`[dyrected/core] Auto-seeding global: ${this.global.slug}`);
|
|
318
|
+
await db.updateGlobal({ slug: this.global.slug, data: initialData });
|
|
319
|
+
return c.json({ message: "Seed successful", data: initialData }, 201);
|
|
320
|
+
}
|
|
182
321
|
};
|
|
183
322
|
|
|
184
323
|
// src/controllers/media.controller.ts
|
|
185
324
|
var MediaController = class {
|
|
325
|
+
collection;
|
|
326
|
+
constructor(collection = "media") {
|
|
327
|
+
this.collection = collection;
|
|
328
|
+
}
|
|
186
329
|
async upload(c) {
|
|
187
|
-
const
|
|
188
|
-
const storage =
|
|
330
|
+
const config2 = c.get("config");
|
|
331
|
+
const storage = config2.storage;
|
|
332
|
+
const imageService = config2.image;
|
|
189
333
|
if (!storage) {
|
|
190
334
|
return c.json({ message: "Storage not configured" }, 500);
|
|
191
335
|
}
|
|
192
336
|
const body = await c.req.parseBody();
|
|
193
337
|
const file = body["file"];
|
|
338
|
+
const focalPointStr = body["focalPoint"];
|
|
339
|
+
const focalPoint = focalPointStr ? JSON.parse(focalPointStr) : void 0;
|
|
194
340
|
if (!file) {
|
|
195
341
|
return c.json({ message: "No file uploaded" }, 400);
|
|
196
342
|
}
|
|
197
343
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
344
|
+
const siteId = c.get("siteId");
|
|
345
|
+
const workspaceId = c.get("workspaceId");
|
|
346
|
+
const prefix = workspaceId ? `${workspaceId}/${siteId}` : siteId || "default";
|
|
347
|
+
let imageMetadata = {};
|
|
348
|
+
let imageSizes = {};
|
|
349
|
+
if (imageService && file.type.startsWith("image/")) {
|
|
350
|
+
let colConfig = config2.collections.find((col) => col.slug === this.collection);
|
|
351
|
+
if (!colConfig && config2.onSchemaFetch && siteId) {
|
|
352
|
+
const dynamic = await config2.onSchemaFetch(siteId);
|
|
353
|
+
colConfig = dynamic.collections?.find((col) => col.slug === this.collection);
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const processed = await imageService.process({
|
|
357
|
+
buffer,
|
|
358
|
+
mimeType: file.type,
|
|
359
|
+
config: colConfig?.upload,
|
|
360
|
+
focalPoint
|
|
361
|
+
});
|
|
362
|
+
imageMetadata = processed.metadata;
|
|
363
|
+
imageSizes = processed.sizes;
|
|
364
|
+
} catch (err) {
|
|
365
|
+
console.error("[MediaController] Image processing failed:", err);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
198
368
|
const fileData = await storage.upload({
|
|
199
369
|
filename: file.name,
|
|
200
370
|
buffer,
|
|
201
|
-
mimeType: file.type
|
|
371
|
+
mimeType: file.type,
|
|
372
|
+
prefix
|
|
202
373
|
});
|
|
203
|
-
const
|
|
374
|
+
const finalFileData = {
|
|
375
|
+
...fileData,
|
|
376
|
+
...imageMetadata,
|
|
377
|
+
focalPoint,
|
|
378
|
+
sizes: {}
|
|
379
|
+
};
|
|
380
|
+
if (imageSizes) {
|
|
381
|
+
for (const [sizeName, sizeData] of Object.entries(imageSizes)) {
|
|
382
|
+
const ext = file.name.split(".").pop();
|
|
383
|
+
const baseName = file.name.substring(0, file.name.lastIndexOf("."));
|
|
384
|
+
const sizeFilename = `${baseName}-${sizeName}.${ext}`;
|
|
385
|
+
try {
|
|
386
|
+
const sizeFileData = await storage.upload({
|
|
387
|
+
filename: sizeFilename,
|
|
388
|
+
buffer: sizeData.buffer,
|
|
389
|
+
mimeType: file.type,
|
|
390
|
+
prefix
|
|
391
|
+
});
|
|
392
|
+
finalFileData.sizes[sizeName] = {
|
|
393
|
+
...sizeFileData,
|
|
394
|
+
width: sizeData.width,
|
|
395
|
+
height: sizeData.height
|
|
396
|
+
};
|
|
397
|
+
} catch (err) {
|
|
398
|
+
console.error(`[MediaController] Failed to upload size ${sizeName}:`, err);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const db = config2.db;
|
|
403
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
204
404
|
const doc = await db.create({
|
|
205
|
-
collection:
|
|
206
|
-
data:
|
|
405
|
+
collection: this.collection,
|
|
406
|
+
data: finalFileData
|
|
207
407
|
});
|
|
208
408
|
return c.json(doc, 201);
|
|
209
409
|
}
|
|
210
410
|
async find(c) {
|
|
211
411
|
const db = c.get("config").db;
|
|
412
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
212
413
|
const limit = Number(c.req.query("limit")) || 10;
|
|
213
414
|
const page = Number(c.req.query("page")) || 1;
|
|
214
415
|
const result = await db.find({
|
|
215
|
-
collection:
|
|
416
|
+
collection: this.collection,
|
|
216
417
|
limit,
|
|
217
418
|
page
|
|
218
419
|
});
|
|
219
420
|
return c.json(result);
|
|
220
421
|
}
|
|
221
422
|
async delete(c) {
|
|
222
|
-
const
|
|
223
|
-
const storage =
|
|
224
|
-
const db =
|
|
423
|
+
const config2 = c.get("config");
|
|
424
|
+
const storage = config2.storage;
|
|
425
|
+
const db = config2.db;
|
|
426
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
225
427
|
const id = c.req.param("id");
|
|
226
428
|
if (!id) return c.json({ message: "Missing ID" }, 400);
|
|
227
|
-
const doc = await db.findOne({ collection:
|
|
429
|
+
const doc = await db.findOne({ collection: this.collection, id });
|
|
228
430
|
if (!doc) return c.json({ message: "Not Found" }, 404);
|
|
229
431
|
if (storage) {
|
|
230
432
|
await storage.delete({ filename: doc.filename });
|
|
433
|
+
if (doc.sizes) {
|
|
434
|
+
for (const size of Object.values(doc.sizes)) {
|
|
435
|
+
if (size.filename) {
|
|
436
|
+
await storage.delete({ filename: size.filename });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
231
440
|
}
|
|
232
|
-
await db.delete({ collection:
|
|
441
|
+
await db.delete({ collection: this.collection, id });
|
|
233
442
|
return c.json({ message: "Deleted" });
|
|
234
443
|
}
|
|
444
|
+
async serve(c) {
|
|
445
|
+
const config2 = c.get("config");
|
|
446
|
+
const storage = config2.storage;
|
|
447
|
+
if (!storage || !storage.resolve) {
|
|
448
|
+
return c.json({ message: "Storage not configured for serving" }, 404);
|
|
449
|
+
}
|
|
450
|
+
const filename = c.req.param("filename");
|
|
451
|
+
if (!filename) return c.json({ message: "Missing filename" }, 400);
|
|
452
|
+
let res = await storage.resolve({ filename });
|
|
453
|
+
if (!res && !filename.includes("/")) {
|
|
454
|
+
res = await storage.resolve({ filename: `default/${filename}` });
|
|
455
|
+
}
|
|
456
|
+
if (!res) return c.json({ message: "Not Found" }, 404);
|
|
457
|
+
c.header("Content-Type", res.mimeType);
|
|
458
|
+
return c.body(res.buffer);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// src/auth/password.ts
|
|
463
|
+
import { promisify } from "util";
|
|
464
|
+
import { scrypt, randomBytes, timingSafeEqual } from "crypto";
|
|
465
|
+
var scryptAsync = promisify(scrypt);
|
|
466
|
+
var SALT_LEN = 16;
|
|
467
|
+
var KEY_LEN = 64;
|
|
468
|
+
async function hashPassword(plain) {
|
|
469
|
+
const salt = randomBytes(SALT_LEN).toString("hex");
|
|
470
|
+
const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
|
|
471
|
+
return `${salt}:${derivedKey.toString("hex")}`;
|
|
472
|
+
}
|
|
473
|
+
async function verifyPassword(plain, stored) {
|
|
474
|
+
const [salt, storedHash] = stored.split(":");
|
|
475
|
+
if (!salt || !storedHash) return false;
|
|
476
|
+
const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
|
|
477
|
+
const storedBuffer = Buffer.from(storedHash, "hex");
|
|
478
|
+
if (derivedKey.length !== storedBuffer.length) return false;
|
|
479
|
+
return timingSafeEqual(derivedKey, storedBuffer);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/auth/token.ts
|
|
483
|
+
import { SignJWT, jwtVerify, decodeJwt } from "jose";
|
|
484
|
+
import { TextEncoder } from "util";
|
|
485
|
+
function getSecret() {
|
|
486
|
+
const secret = process.env.DYRECTED_JWT_SECRET || process.env.JWT_SECRET;
|
|
487
|
+
if (!secret) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
"[dyrected/core] DYRECTED_JWT_SECRET is not set. Add it to your environment variables to enable auth collections."
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
return new TextEncoder().encode(secret);
|
|
493
|
+
}
|
|
494
|
+
var DEFAULT_EXPIRY = "7d";
|
|
495
|
+
async function signCollectionToken(payload, expiresIn = DEFAULT_EXPIRY) {
|
|
496
|
+
return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiresIn).sign(getSecret());
|
|
497
|
+
}
|
|
498
|
+
async function verifyCollectionToken(token) {
|
|
499
|
+
const { payload } = await jwtVerify(token, getSecret());
|
|
500
|
+
return payload;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/services/email.service.ts
|
|
504
|
+
var _devSend = null;
|
|
505
|
+
var _devSendPromise = null;
|
|
506
|
+
async function getDevSend() {
|
|
507
|
+
if (_devSend) return _devSend;
|
|
508
|
+
if (_devSendPromise) return _devSendPromise;
|
|
509
|
+
_devSendPromise = (async () => {
|
|
510
|
+
try {
|
|
511
|
+
const nodemailer = await import("nodemailer");
|
|
512
|
+
const account = await nodemailer.default.createTestAccount();
|
|
513
|
+
const transport = nodemailer.default.createTransport({
|
|
514
|
+
host: "smtp.ethereal.email",
|
|
515
|
+
port: 587,
|
|
516
|
+
auth: { user: account.user, pass: account.pass }
|
|
517
|
+
});
|
|
518
|
+
console.log("[dyrected/core] No email config \u2014 using Ethereal for dev email preview.");
|
|
519
|
+
console.log(`[dyrected/core] Ethereal login: https://ethereal.email user: ${account.user} pass: ${account.pass}`);
|
|
520
|
+
_devSend = async ({ to, subject, html }) => {
|
|
521
|
+
const info = await transport.sendMail({ from: '"Dyrected Dev" <dev@dyrected.local>', to, subject, html });
|
|
522
|
+
console.log(`[dyrected/core] Email preview URL: ${nodemailer.default.getTestMessageUrl(info)}`);
|
|
523
|
+
};
|
|
524
|
+
return _devSend;
|
|
525
|
+
} catch {
|
|
526
|
+
console.warn("[dyrected/core] nodemailer not available \u2014 emails will not be sent in dev.");
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
})();
|
|
530
|
+
return _devSendPromise;
|
|
531
|
+
}
|
|
532
|
+
async function sendEmail(config2, payload) {
|
|
533
|
+
if (config2.email) {
|
|
534
|
+
await config2.email.send(payload);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (process.env.NODE_ENV !== "production") {
|
|
538
|
+
const devSend = await getDevSend();
|
|
539
|
+
await devSend?.(payload);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function buildWelcomeEmail(config2, args) {
|
|
543
|
+
const custom = config2.email?.templates?.welcome?.(args);
|
|
544
|
+
return {
|
|
545
|
+
subject: custom?.subject ?? "Welcome \u2014 your account is ready",
|
|
546
|
+
html: custom?.html ?? `
|
|
547
|
+
<div style="font-family:sans-serif;max-width:600px;margin:0 auto">
|
|
548
|
+
<h2>Welcome!</h2>
|
|
549
|
+
<p>Your account has been created. You can now log in with:</p>
|
|
550
|
+
<p><strong>${args.email}</strong></p>
|
|
551
|
+
</div>`
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
function buildInviteEmail(config2, args) {
|
|
555
|
+
const custom = config2.email?.templates?.invite?.(args);
|
|
556
|
+
return {
|
|
557
|
+
subject: custom?.subject ?? "You've been invited",
|
|
558
|
+
html: custom?.html ?? `
|
|
559
|
+
<div style="font-family:sans-serif;max-width:600px;margin:0 auto">
|
|
560
|
+
<h2>You've been invited</h2>
|
|
561
|
+
${args.invitedByEmail ? `<p>Invited by <strong>${args.invitedByEmail}</strong>.</p>` : ""}
|
|
562
|
+
<p>Use the token below to accept your invitation. It expires in 7 days.</p>
|
|
563
|
+
<pre style="background:#f4f4f4;padding:12px;border-radius:4px;word-break:break-all">${args.token}</pre>
|
|
564
|
+
</div>`
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function buildResetPasswordEmail(config2, args) {
|
|
568
|
+
const custom = config2.email?.templates?.resetPassword?.(args);
|
|
569
|
+
return {
|
|
570
|
+
subject: custom?.subject ?? "Reset your password",
|
|
571
|
+
html: custom?.html ?? `
|
|
572
|
+
<div style="font-family:sans-serif;max-width:600px;margin:0 auto">
|
|
573
|
+
<h2>Reset your password</h2>
|
|
574
|
+
<p>Use the token below to reset your password. It expires in 1 hour.</p>
|
|
575
|
+
<pre style="background:#f4f4f4;padding:12px;border-radius:4px;word-break:break-all">${args.token}</pre>
|
|
576
|
+
</div>`
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function buildPasswordChangedEmail(config2, args) {
|
|
580
|
+
const custom = config2.email?.templates?.passwordChanged?.(args);
|
|
581
|
+
return {
|
|
582
|
+
subject: custom?.subject ?? "Your password has been changed",
|
|
583
|
+
html: custom?.html ?? `
|
|
584
|
+
<div style="font-family:sans-serif;max-width:600px;margin:0 auto">
|
|
585
|
+
<h2>Password changed</h2>
|
|
586
|
+
<p>The password for <strong>${args.email}</strong> was just changed.</p>
|
|
587
|
+
<p>If you did not make this change, please contact support immediately.</p>
|
|
588
|
+
</div>`
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// src/controllers/auth.controller.ts
|
|
593
|
+
var AuthController = class {
|
|
594
|
+
constructor(collection) {
|
|
595
|
+
this.collection = collection;
|
|
596
|
+
}
|
|
597
|
+
collection;
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
// GET /init
|
|
600
|
+
// Checks if the first user needs to be created.
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
async init(c) {
|
|
603
|
+
const db = c.get("config").db;
|
|
604
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
605
|
+
const result = await db.find({
|
|
606
|
+
collection: this.collection.slug,
|
|
607
|
+
limit: 1
|
|
608
|
+
});
|
|
609
|
+
return c.json({
|
|
610
|
+
initialized: result.total > 0
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
// POST /first-user
|
|
615
|
+
// Creates the first user if none exist.
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
async registerFirstUser(c) {
|
|
618
|
+
const db = c.get("config").db;
|
|
619
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
620
|
+
const check = await db.find({
|
|
621
|
+
collection: this.collection.slug,
|
|
622
|
+
limit: 1
|
|
623
|
+
});
|
|
624
|
+
if (check.total > 0) {
|
|
625
|
+
return c.json({ error: true, message: "Initial user already exists." }, 403);
|
|
626
|
+
}
|
|
627
|
+
const body = await c.req.json().catch(() => null);
|
|
628
|
+
if (!body?.email || !body?.password) {
|
|
629
|
+
return c.json({ error: true, message: "email and password are required." }, 400);
|
|
630
|
+
}
|
|
631
|
+
const hashedPassword = await hashPassword(body.password);
|
|
632
|
+
const user = await db.create({
|
|
633
|
+
collection: this.collection.slug,
|
|
634
|
+
data: {
|
|
635
|
+
...body,
|
|
636
|
+
password: hashedPassword,
|
|
637
|
+
roles: ["admin"]
|
|
638
|
+
// Default first user to admin
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
const token = await signCollectionToken({
|
|
642
|
+
sub: user.id,
|
|
643
|
+
email: user.email,
|
|
644
|
+
collection: this.collection.slug
|
|
645
|
+
});
|
|
646
|
+
const { subject, html } = buildWelcomeEmail(config, { email: body.email });
|
|
647
|
+
sendEmail(config, { to: body.email, subject, html }).catch(
|
|
648
|
+
(err) => console.error("[dyrected/core] Failed to send welcome email:", err)
|
|
649
|
+
);
|
|
650
|
+
const { password: _, ...safeUser } = user;
|
|
651
|
+
return c.json({ token, user: safeUser });
|
|
652
|
+
}
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
// POST /login
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
async login(c) {
|
|
657
|
+
const db = c.get("config").db;
|
|
658
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
659
|
+
const body = await c.req.json().catch(() => null);
|
|
660
|
+
if (!body?.email || !body?.password) {
|
|
661
|
+
return c.json({ error: true, message: "email and password are required." }, 400);
|
|
662
|
+
}
|
|
663
|
+
const result = await db.find({
|
|
664
|
+
collection: this.collection.slug,
|
|
665
|
+
where: { email: body.email },
|
|
666
|
+
limit: 1
|
|
667
|
+
});
|
|
668
|
+
const user = result.docs[0];
|
|
669
|
+
if (!user) {
|
|
670
|
+
return c.json({ error: true, message: "Invalid email or password." }, 401);
|
|
671
|
+
}
|
|
672
|
+
const valid = await verifyPassword(body.password, user.password);
|
|
673
|
+
if (!valid) {
|
|
674
|
+
return c.json({ error: true, message: "Invalid email or password." }, 401);
|
|
675
|
+
}
|
|
676
|
+
const token = await signCollectionToken({
|
|
677
|
+
sub: user.id,
|
|
678
|
+
email: user.email,
|
|
679
|
+
collection: this.collection.slug
|
|
680
|
+
});
|
|
681
|
+
const { password: _, ...safeUser } = user;
|
|
682
|
+
return c.json({ token, user: safeUser });
|
|
683
|
+
}
|
|
684
|
+
// ---------------------------------------------------------------------------
|
|
685
|
+
// POST /logout
|
|
686
|
+
// Auth collections use stateless JWTs — logout is handled client-side.
|
|
687
|
+
// This endpoint exists so clients have a consistent API surface.
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
async logout(c) {
|
|
690
|
+
return c.json({ success: true, message: "Logged out. Discard your token." });
|
|
691
|
+
}
|
|
692
|
+
// ---------------------------------------------------------------------------
|
|
693
|
+
// GET /me
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
async me(c) {
|
|
696
|
+
const db = c.get("config").db;
|
|
697
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
698
|
+
const requestUser = c.get("user");
|
|
699
|
+
if (!requestUser) {
|
|
700
|
+
return c.json({ error: true, message: "Authentication required." }, 401);
|
|
701
|
+
}
|
|
702
|
+
const user = await db.findOne({ collection: this.collection.slug, id: requestUser.sub });
|
|
703
|
+
if (!user) {
|
|
704
|
+
return c.json({ error: true, message: "User not found." }, 404);
|
|
705
|
+
}
|
|
706
|
+
const { password: _, ...safeUser } = user;
|
|
707
|
+
return c.json(safeUser);
|
|
708
|
+
}
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// POST /refresh-token
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
async refreshToken(c) {
|
|
713
|
+
const requestUser = c.get("user");
|
|
714
|
+
if (!requestUser) {
|
|
715
|
+
return c.json({ error: true, message: "Authentication required." }, 401);
|
|
716
|
+
}
|
|
717
|
+
const token = await signCollectionToken({
|
|
718
|
+
sub: requestUser.sub,
|
|
719
|
+
email: requestUser.email,
|
|
720
|
+
collection: this.collection.slug
|
|
721
|
+
});
|
|
722
|
+
return c.json({ token });
|
|
723
|
+
}
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
// POST /forgot-password
|
|
726
|
+
// Requires config.email to be set. Silently succeeds if email not found
|
|
727
|
+
// to prevent email enumeration.
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
async forgotPassword(c) {
|
|
730
|
+
const config2 = c.get("config");
|
|
731
|
+
const db = config2.db;
|
|
732
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
733
|
+
const body = await c.req.json().catch(() => null);
|
|
734
|
+
if (!body?.email) {
|
|
735
|
+
return c.json({ error: true, message: "email is required." }, 400);
|
|
736
|
+
}
|
|
737
|
+
const result = await db.find({
|
|
738
|
+
collection: this.collection.slug,
|
|
739
|
+
where: { email: body.email },
|
|
740
|
+
limit: 1
|
|
741
|
+
});
|
|
742
|
+
const user = result.docs[0];
|
|
743
|
+
if (user) {
|
|
744
|
+
const resetToken = await signCollectionToken(
|
|
745
|
+
{ sub: user.id, email: user.email, collection: this.collection.slug, purpose: "reset" },
|
|
746
|
+
"1h"
|
|
747
|
+
);
|
|
748
|
+
try {
|
|
749
|
+
const { subject, html } = buildResetPasswordEmail(config2, { token: resetToken });
|
|
750
|
+
await sendEmail(config2, { to: user.email, subject, html });
|
|
751
|
+
} catch (err) {
|
|
752
|
+
console.error("[dyrected/core] Failed to send password reset email:", err);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return c.json({
|
|
756
|
+
success: true,
|
|
757
|
+
message: "If an account with that email exists, a reset link has been sent."
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
// POST /reset-password
|
|
762
|
+
// Expects { token: string, password: string } in body.
|
|
763
|
+
// The token is the reset JWT issued by /forgot-password.
|
|
764
|
+
// ---------------------------------------------------------------------------
|
|
765
|
+
async resetPassword(c) {
|
|
766
|
+
const db = c.get("config").db;
|
|
767
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
768
|
+
const body = await c.req.json().catch(() => null);
|
|
769
|
+
if (!body?.token || !body?.password) {
|
|
770
|
+
return c.json({ error: true, message: "token and password are required." }, 400);
|
|
771
|
+
}
|
|
772
|
+
let payload;
|
|
773
|
+
try {
|
|
774
|
+
payload = await verifyCollectionToken(body.token);
|
|
775
|
+
} catch {
|
|
776
|
+
return c.json({ error: true, message: "Reset token is invalid or has expired." }, 400);
|
|
777
|
+
}
|
|
778
|
+
if (payload.collection !== this.collection.slug || payload.purpose !== "reset") {
|
|
779
|
+
return c.json({ error: true, message: "Reset token is invalid or has expired." }, 400);
|
|
780
|
+
}
|
|
781
|
+
const hashedPassword = await hashPassword(body.password);
|
|
782
|
+
await db.update({
|
|
783
|
+
collection: this.collection.slug,
|
|
784
|
+
id: payload.sub,
|
|
785
|
+
data: { password: hashedPassword }
|
|
786
|
+
});
|
|
787
|
+
const { subject, html } = buildPasswordChangedEmail(config, { email: payload.email });
|
|
788
|
+
sendEmail(config, { to: payload.email, subject, html }).catch(
|
|
789
|
+
(err) => console.error("[dyrected/core] Failed to send password-changed email:", err)
|
|
790
|
+
);
|
|
791
|
+
return c.json({ success: true, message: "Password has been reset. You can now log in." });
|
|
792
|
+
}
|
|
793
|
+
// ---------------------------------------------------------------------------
|
|
794
|
+
// POST /invite
|
|
795
|
+
// Requires auth. Issues a signed invite token and emails it to the invitee.
|
|
796
|
+
// ---------------------------------------------------------------------------
|
|
797
|
+
async invite(c) {
|
|
798
|
+
const config2 = c.get("config");
|
|
799
|
+
const db = config2.db;
|
|
800
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
801
|
+
const requestUser = c.get("user");
|
|
802
|
+
if (!requestUser) {
|
|
803
|
+
return c.json({ error: true, message: "Authentication required." }, 401);
|
|
804
|
+
}
|
|
805
|
+
const body = await c.req.json().catch(() => null);
|
|
806
|
+
if (!body?.email) {
|
|
807
|
+
return c.json({ error: true, message: "email is required." }, 400);
|
|
808
|
+
}
|
|
809
|
+
const existing = await db.find({
|
|
810
|
+
collection: this.collection.slug,
|
|
811
|
+
where: { email: body.email },
|
|
812
|
+
limit: 1
|
|
813
|
+
});
|
|
814
|
+
if (existing.total > 0) {
|
|
815
|
+
return c.json({ error: true, message: "An account with that email already exists." }, 409);
|
|
816
|
+
}
|
|
817
|
+
const inviteToken = await signCollectionToken(
|
|
818
|
+
{ sub: body.email, email: body.email, collection: this.collection.slug, purpose: "invite" },
|
|
819
|
+
"7d"
|
|
820
|
+
);
|
|
821
|
+
try {
|
|
822
|
+
const { subject, html } = buildInviteEmail(config2, {
|
|
823
|
+
token: inviteToken,
|
|
824
|
+
invitedByEmail: requestUser.email
|
|
825
|
+
});
|
|
826
|
+
await sendEmail(config2, { to: body.email, subject, html });
|
|
827
|
+
} catch (err) {
|
|
828
|
+
console.error("[dyrected/core] Failed to send invite email:", err);
|
|
829
|
+
}
|
|
830
|
+
return c.json({ success: true, message: `Invite sent to ${body.email}.` });
|
|
831
|
+
}
|
|
832
|
+
// ---------------------------------------------------------------------------
|
|
833
|
+
// POST /accept-invite
|
|
834
|
+
// Public. Validates the invite token and creates the user account.
|
|
835
|
+
// Body: { token, password, ...extraFields }
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
async acceptInvite(c) {
|
|
838
|
+
const config2 = c.get("config");
|
|
839
|
+
const db = config2.db;
|
|
840
|
+
if (!db) return c.json({ message: "Database not configured" }, 500);
|
|
841
|
+
const body = await c.req.json().catch(() => null);
|
|
842
|
+
if (!body?.token || !body?.password) {
|
|
843
|
+
return c.json({ error: true, message: "token and password are required." }, 400);
|
|
844
|
+
}
|
|
845
|
+
let payload;
|
|
846
|
+
try {
|
|
847
|
+
payload = await verifyCollectionToken(body.token);
|
|
848
|
+
} catch {
|
|
849
|
+
return c.json({ error: true, message: "Invite token is invalid or has expired." }, 400);
|
|
850
|
+
}
|
|
851
|
+
if (payload.collection !== this.collection.slug || payload.purpose !== "invite") {
|
|
852
|
+
return c.json({ error: true, message: "Invite token is invalid or has expired." }, 400);
|
|
853
|
+
}
|
|
854
|
+
const inviteeEmail = payload.sub;
|
|
855
|
+
const existing = await db.find({
|
|
856
|
+
collection: this.collection.slug,
|
|
857
|
+
where: { email: inviteeEmail },
|
|
858
|
+
limit: 1
|
|
859
|
+
});
|
|
860
|
+
if (existing.total > 0) {
|
|
861
|
+
return c.json({ error: true, message: "An account with that email already exists." }, 409);
|
|
862
|
+
}
|
|
863
|
+
const { token: _t, password: _p, ...extraFields } = body;
|
|
864
|
+
const hashedPassword = await hashPassword(body.password);
|
|
865
|
+
const user = await db.create({
|
|
866
|
+
collection: this.collection.slug,
|
|
867
|
+
data: { ...extraFields, email: inviteeEmail, password: hashedPassword }
|
|
868
|
+
});
|
|
869
|
+
const sessionToken = await signCollectionToken({
|
|
870
|
+
sub: user.id,
|
|
871
|
+
email: inviteeEmail,
|
|
872
|
+
collection: this.collection.slug
|
|
873
|
+
});
|
|
874
|
+
const { subject, html } = buildWelcomeEmail(config2, { email: inviteeEmail });
|
|
875
|
+
sendEmail(config2, { to: inviteeEmail, subject, html }).catch(
|
|
876
|
+
(err) => console.error("[dyrected/core] Failed to send welcome email:", err)
|
|
877
|
+
);
|
|
878
|
+
const { password: _, ...safeUser } = user;
|
|
879
|
+
return c.json({ token: sessionToken, user: safeUser }, 201);
|
|
880
|
+
}
|
|
235
881
|
};
|
|
236
882
|
|
|
883
|
+
// src/controllers/preview.controller.ts
|
|
884
|
+
import { SignJWT as SignJWT2, jwtVerify as jwtVerify2 } from "jose";
|
|
885
|
+
import { TextEncoder as TextEncoder2 } from "util";
|
|
886
|
+
var PreviewController = class {
|
|
887
|
+
getSecret() {
|
|
888
|
+
const secret = process.env.DYRECTED_JWT_SECRET || process.env.JWT_SECRET || "dyrected-preview-secret-change-me";
|
|
889
|
+
return new TextEncoder2().encode(secret);
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* POST /api/preview-token
|
|
893
|
+
* Generates a short-lived token for previewing unsaved data.
|
|
894
|
+
*/
|
|
895
|
+
async createToken(c) {
|
|
896
|
+
const body = await c.req.json().catch(() => null);
|
|
897
|
+
if (!body?.collectionSlug || !body?.data) {
|
|
898
|
+
return c.json({ error: true, message: "collectionSlug and data are required." }, 400);
|
|
899
|
+
}
|
|
900
|
+
const token = await new SignJWT2({
|
|
901
|
+
collectionSlug: body.collectionSlug,
|
|
902
|
+
documentId: body.documentId,
|
|
903
|
+
data: body.data
|
|
904
|
+
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime("15m").sign(this.getSecret());
|
|
905
|
+
const expiresAt = new Date(Date.now() + 15 * 60 * 1e3).toISOString();
|
|
906
|
+
return c.json({ token, expiresAt });
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* GET /api/preview-data?token=<jwt>
|
|
910
|
+
* Returns the data stored in the preview token.
|
|
911
|
+
*/
|
|
912
|
+
async getData(c) {
|
|
913
|
+
const token = c.req.query("token");
|
|
914
|
+
if (!token) {
|
|
915
|
+
return c.json({ error: true, message: "token query parameter is required." }, 400);
|
|
916
|
+
}
|
|
917
|
+
try {
|
|
918
|
+
const { payload } = await jwtVerify2(token, this.getSecret());
|
|
919
|
+
return c.json(payload);
|
|
920
|
+
} catch (err) {
|
|
921
|
+
return c.json({ error: true, message: "Invalid or expired preview token." }, 401);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
// src/middleware/auth.ts
|
|
927
|
+
function requireAuth() {
|
|
928
|
+
return async (c, next) => {
|
|
929
|
+
const authHeader = c.req.header("Authorization");
|
|
930
|
+
const token = authHeader?.replace(/^Bearer\s+/i, "");
|
|
931
|
+
if (!token) {
|
|
932
|
+
return c.json({ error: true, message: "Authentication required." }, 401);
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
const user = await verifyCollectionToken(token);
|
|
936
|
+
c.set("user", user);
|
|
937
|
+
await next();
|
|
938
|
+
} catch {
|
|
939
|
+
return c.json({ error: true, message: "Invalid or expired token." }, 401);
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
function optionalAuth() {
|
|
944
|
+
return async (c, next) => {
|
|
945
|
+
const authHeader = c.req.header("Authorization");
|
|
946
|
+
const token = authHeader?.replace(/^Bearer\s+/i, "");
|
|
947
|
+
if (token) {
|
|
948
|
+
try {
|
|
949
|
+
const user = await verifyCollectionToken(token);
|
|
950
|
+
c.set("user", user);
|
|
951
|
+
} catch {
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
await next();
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// src/utils/openapi.ts
|
|
959
|
+
function generateOpenApi(config2) {
|
|
960
|
+
const spec = {
|
|
961
|
+
openapi: "3.0.0",
|
|
962
|
+
info: {
|
|
963
|
+
title: "Dyrected API",
|
|
964
|
+
version: "1.0.0",
|
|
965
|
+
description: "Automatically generated OpenAPI specification for the Dyrected project."
|
|
966
|
+
},
|
|
967
|
+
components: {
|
|
968
|
+
schemas: {},
|
|
969
|
+
securitySchemes: {
|
|
970
|
+
ApiKeyAuth: {
|
|
971
|
+
type: "apiKey",
|
|
972
|
+
in: "header",
|
|
973
|
+
name: "x-api-key"
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
},
|
|
977
|
+
paths: {},
|
|
978
|
+
security: [{ ApiKeyAuth: [] }]
|
|
979
|
+
};
|
|
980
|
+
for (const collection of config2.collections) {
|
|
981
|
+
spec.components.schemas[collection.slug] = collectionToSchema(collection);
|
|
982
|
+
}
|
|
983
|
+
for (const global of config2.globals) {
|
|
984
|
+
spec.components.schemas[global.slug] = globalToSchema(global);
|
|
985
|
+
}
|
|
986
|
+
for (const collection of config2.collections) {
|
|
987
|
+
const slug = collection.slug;
|
|
988
|
+
const path = `/api/collections/${slug}`;
|
|
989
|
+
const labels = collection.labels || { singular: slug, plural: `${slug}s` };
|
|
990
|
+
spec.paths[path] = {
|
|
991
|
+
get: {
|
|
992
|
+
tags: ["Collections"],
|
|
993
|
+
summary: `Find ${labels.plural}`,
|
|
994
|
+
parameters: [
|
|
995
|
+
{ name: "limit", in: "query", schema: { type: "integer", default: 10 } },
|
|
996
|
+
{ name: "page", in: "query", schema: { type: "integer", default: 1 } },
|
|
997
|
+
{ name: "where", in: "query", schema: { type: "string" }, description: "JSON filter" },
|
|
998
|
+
{ name: "sort", in: "query", schema: { type: "string" }, description: "Sort field (e.g. -createdAt)" }
|
|
999
|
+
],
|
|
1000
|
+
responses: {
|
|
1001
|
+
200: {
|
|
1002
|
+
description: "Success",
|
|
1003
|
+
content: {
|
|
1004
|
+
"application/json": {
|
|
1005
|
+
schema: {
|
|
1006
|
+
type: "object",
|
|
1007
|
+
properties: {
|
|
1008
|
+
docs: { type: "array", items: { $ref: `#/components/schemas/${slug}` } },
|
|
1009
|
+
total: { type: "integer" },
|
|
1010
|
+
limit: { type: "integer" },
|
|
1011
|
+
page: { type: "integer" }
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
},
|
|
1019
|
+
post: {
|
|
1020
|
+
tags: ["Collections"],
|
|
1021
|
+
summary: `Create ${labels.singular}`,
|
|
1022
|
+
requestBody: {
|
|
1023
|
+
required: true,
|
|
1024
|
+
content: {
|
|
1025
|
+
"application/json": {
|
|
1026
|
+
schema: { $ref: `#/components/schemas/${slug}` }
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
},
|
|
1030
|
+
responses: {
|
|
1031
|
+
201: {
|
|
1032
|
+
description: "Created",
|
|
1033
|
+
content: {
|
|
1034
|
+
"application/json": {
|
|
1035
|
+
schema: { $ref: `#/components/schemas/${slug}` }
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
spec.paths[`${path}/{id}`] = {
|
|
1043
|
+
get: {
|
|
1044
|
+
tags: ["Collections"],
|
|
1045
|
+
summary: `Get a single ${labels.singular}`,
|
|
1046
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
1047
|
+
responses: {
|
|
1048
|
+
200: {
|
|
1049
|
+
description: "Success",
|
|
1050
|
+
content: {
|
|
1051
|
+
"application/json": {
|
|
1052
|
+
schema: { $ref: `#/components/schemas/${slug}` }
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
},
|
|
1058
|
+
patch: {
|
|
1059
|
+
tags: ["Collections"],
|
|
1060
|
+
summary: `Update ${labels.singular}`,
|
|
1061
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
1062
|
+
requestBody: {
|
|
1063
|
+
required: true,
|
|
1064
|
+
content: {
|
|
1065
|
+
"application/json": {
|
|
1066
|
+
schema: { $ref: `#/components/schemas/${slug}` }
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
},
|
|
1070
|
+
responses: {
|
|
1071
|
+
200: {
|
|
1072
|
+
description: "Updated",
|
|
1073
|
+
content: {
|
|
1074
|
+
"application/json": {
|
|
1075
|
+
schema: { $ref: `#/components/schemas/${slug}` }
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
},
|
|
1081
|
+
delete: {
|
|
1082
|
+
tags: ["Collections"],
|
|
1083
|
+
summary: `Delete ${labels.singular}`,
|
|
1084
|
+
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
|
|
1085
|
+
responses: {
|
|
1086
|
+
204: { description: "Deleted" }
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
for (const global of config2.globals) {
|
|
1092
|
+
const slug = global.slug;
|
|
1093
|
+
const path = `/api/globals/${slug}`;
|
|
1094
|
+
spec.paths[path] = {
|
|
1095
|
+
get: {
|
|
1096
|
+
tags: ["Globals"],
|
|
1097
|
+
summary: `Get ${global.label || slug}`,
|
|
1098
|
+
responses: {
|
|
1099
|
+
200: {
|
|
1100
|
+
description: "Success",
|
|
1101
|
+
content: {
|
|
1102
|
+
"application/json": {
|
|
1103
|
+
schema: { $ref: `#/components/schemas/${slug}` }
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
},
|
|
1109
|
+
patch: {
|
|
1110
|
+
tags: ["Globals"],
|
|
1111
|
+
summary: `Update ${global.label || slug}`,
|
|
1112
|
+
requestBody: {
|
|
1113
|
+
required: true,
|
|
1114
|
+
content: {
|
|
1115
|
+
"application/json": {
|
|
1116
|
+
schema: { $ref: `#/components/schemas/${slug}` }
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
},
|
|
1120
|
+
responses: {
|
|
1121
|
+
200: {
|
|
1122
|
+
description: "Updated",
|
|
1123
|
+
content: {
|
|
1124
|
+
"application/json": {
|
|
1125
|
+
schema: { $ref: `#/components/schemas/${slug}` }
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
if (config2.storage) {
|
|
1134
|
+
spec.paths["/api/media"] = {
|
|
1135
|
+
get: {
|
|
1136
|
+
tags: ["Media"],
|
|
1137
|
+
summary: "List Media",
|
|
1138
|
+
responses: {
|
|
1139
|
+
200: {
|
|
1140
|
+
description: "Success",
|
|
1141
|
+
content: {
|
|
1142
|
+
"application/json": {
|
|
1143
|
+
schema: {
|
|
1144
|
+
type: "object",
|
|
1145
|
+
properties: {
|
|
1146
|
+
docs: { type: "array", items: { type: "object", additionalProperties: true } }
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
},
|
|
1154
|
+
post: {
|
|
1155
|
+
tags: ["Media"],
|
|
1156
|
+
summary: "Upload Media",
|
|
1157
|
+
requestBody: {
|
|
1158
|
+
content: {
|
|
1159
|
+
"multipart/form-data": {
|
|
1160
|
+
schema: {
|
|
1161
|
+
type: "object",
|
|
1162
|
+
properties: {
|
|
1163
|
+
file: { type: "string", format: "binary" }
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
},
|
|
1169
|
+
responses: {
|
|
1170
|
+
201: { description: "Uploaded" }
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
return spec;
|
|
1176
|
+
}
|
|
1177
|
+
function collectionToSchema(collection) {
|
|
1178
|
+
const { properties, required } = fieldsToProperties(collection.fields);
|
|
1179
|
+
return {
|
|
1180
|
+
type: "object",
|
|
1181
|
+
properties: {
|
|
1182
|
+
id: { type: "string" },
|
|
1183
|
+
createdAt: { type: "string", format: "date-time" },
|
|
1184
|
+
updatedAt: { type: "string", format: "date-time" },
|
|
1185
|
+
...properties
|
|
1186
|
+
},
|
|
1187
|
+
required: ["id", ...required]
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
function globalToSchema(global) {
|
|
1191
|
+
const { properties, required } = fieldsToProperties(global.fields);
|
|
1192
|
+
return {
|
|
1193
|
+
type: "object",
|
|
1194
|
+
properties,
|
|
1195
|
+
required
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
function fieldsToProperties(fields) {
|
|
1199
|
+
const props = {};
|
|
1200
|
+
const required = [];
|
|
1201
|
+
for (const field of fields) {
|
|
1202
|
+
props[field.name] = fieldToSchema(field);
|
|
1203
|
+
if (field.required) {
|
|
1204
|
+
required.push(field.name);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
return { properties: props, required };
|
|
1208
|
+
}
|
|
1209
|
+
function fieldToSchema(field) {
|
|
1210
|
+
let schema = {};
|
|
1211
|
+
switch (field.type) {
|
|
1212
|
+
case "text":
|
|
1213
|
+
case "textarea":
|
|
1214
|
+
case "email":
|
|
1215
|
+
case "url":
|
|
1216
|
+
schema = { type: "string" };
|
|
1217
|
+
break;
|
|
1218
|
+
case "number":
|
|
1219
|
+
schema = { type: "number" };
|
|
1220
|
+
break;
|
|
1221
|
+
case "boolean":
|
|
1222
|
+
schema = { type: "boolean" };
|
|
1223
|
+
break;
|
|
1224
|
+
case "date":
|
|
1225
|
+
schema = { type: "string", format: "date-time" };
|
|
1226
|
+
break;
|
|
1227
|
+
case "select":
|
|
1228
|
+
schema = { type: "string", enum: field.options?.map((o) => typeof o === "string" ? o : o.value) };
|
|
1229
|
+
break;
|
|
1230
|
+
case "multiSelect":
|
|
1231
|
+
schema = { type: "array", items: { type: "string", enum: field.options?.map((o) => typeof o === "string" ? o : o.value) } };
|
|
1232
|
+
break;
|
|
1233
|
+
case "relationship":
|
|
1234
|
+
schema = { type: "string", description: `ID of a ${field.collection} record` };
|
|
1235
|
+
break;
|
|
1236
|
+
case "object": {
|
|
1237
|
+
const { properties, required } = fieldsToProperties(field.fields || []);
|
|
1238
|
+
schema = { type: "object", properties, required };
|
|
1239
|
+
break;
|
|
1240
|
+
}
|
|
1241
|
+
case "array": {
|
|
1242
|
+
const { properties, required } = fieldsToProperties(field.fields || []);
|
|
1243
|
+
schema = { type: "array", items: { type: "object", properties, required } };
|
|
1244
|
+
break;
|
|
1245
|
+
}
|
|
1246
|
+
case "json":
|
|
1247
|
+
case "richText":
|
|
1248
|
+
schema = { type: "object", additionalProperties: true };
|
|
1249
|
+
break;
|
|
1250
|
+
case "blocks":
|
|
1251
|
+
schema = {
|
|
1252
|
+
type: "array",
|
|
1253
|
+
items: {
|
|
1254
|
+
oneOf: field.blocks?.map((block) => {
|
|
1255
|
+
const { properties, required } = fieldsToProperties(block.fields);
|
|
1256
|
+
return {
|
|
1257
|
+
type: "object",
|
|
1258
|
+
properties: {
|
|
1259
|
+
blockType: { type: "string", enum: [block.slug] },
|
|
1260
|
+
...properties
|
|
1261
|
+
},
|
|
1262
|
+
required: ["blockType", ...required]
|
|
1263
|
+
};
|
|
1264
|
+
})
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
break;
|
|
1268
|
+
default:
|
|
1269
|
+
schema = { type: "string" };
|
|
1270
|
+
}
|
|
1271
|
+
if (field.label) schema.description = field.label;
|
|
1272
|
+
return schema;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// src/utils/swagger.ts
|
|
1276
|
+
function getSwaggerHtml(specUrl = "/api/openapi.json") {
|
|
1277
|
+
return `
|
|
1278
|
+
<!DOCTYPE html>
|
|
1279
|
+
<html lang="en">
|
|
1280
|
+
<head>
|
|
1281
|
+
<meta charset="utf-8" />
|
|
1282
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1283
|
+
<meta name="description" content="SwaggerUI" />
|
|
1284
|
+
<title>Dyrected API Documentation</title>
|
|
1285
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
|
|
1286
|
+
</head>
|
|
1287
|
+
<body>
|
|
1288
|
+
<div id="swagger-ui"></div>
|
|
1289
|
+
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" charset="UTF-8"></script>
|
|
1290
|
+
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js" charset="UTF-8"></script>
|
|
1291
|
+
<script>
|
|
1292
|
+
window.onload = () => {
|
|
1293
|
+
// Forward the apikey query param when loading the spec and making API calls
|
|
1294
|
+
const params = new URLSearchParams(window.location.search);
|
|
1295
|
+
const apiKey = params.get('apikey');
|
|
1296
|
+
const specUrlWithKey = apiKey ? '${specUrl}?apikey=' + encodeURIComponent(apiKey) : '${specUrl}';
|
|
1297
|
+
|
|
1298
|
+
window.ui = SwaggerUIBundle({
|
|
1299
|
+
url: specUrlWithKey,
|
|
1300
|
+
dom_id: '#swagger-ui',
|
|
1301
|
+
presets: [
|
|
1302
|
+
SwaggerUIBundle.presets.apis,
|
|
1303
|
+
SwaggerUIStandalonePreset
|
|
1304
|
+
],
|
|
1305
|
+
layout: "BaseLayout",
|
|
1306
|
+
deepLinking: true,
|
|
1307
|
+
showExtensions: true,
|
|
1308
|
+
showCommonExtensions: true,
|
|
1309
|
+
// Inject x-api-key header on every request made from the Swagger UI
|
|
1310
|
+
requestInterceptor: (request) => {
|
|
1311
|
+
if (apiKey) {
|
|
1312
|
+
request.headers['x-api-key'] = apiKey;
|
|
1313
|
+
}
|
|
1314
|
+
return request;
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
};
|
|
1318
|
+
</script>
|
|
1319
|
+
</body>
|
|
1320
|
+
</html>
|
|
1321
|
+
`;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// src/auth/jexl.ts
|
|
1325
|
+
import jexl from "jexl";
|
|
1326
|
+
async function evaluateAccess(expression, context) {
|
|
1327
|
+
if (expression === void 0 || expression === null) return false;
|
|
1328
|
+
if (typeof expression === "boolean") return expression;
|
|
1329
|
+
try {
|
|
1330
|
+
const result = await jexl.eval(expression, context);
|
|
1331
|
+
return !!result;
|
|
1332
|
+
} catch (err) {
|
|
1333
|
+
console.error("[dyrected/core] Jexl evaluation failed:", err);
|
|
1334
|
+
return false;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
237
1338
|
// src/router.ts
|
|
238
|
-
function
|
|
239
|
-
|
|
1339
|
+
function accessGate(target, action) {
|
|
1340
|
+
return async (c, next) => {
|
|
1341
|
+
const user = c.get("user");
|
|
1342
|
+
const accessExpr = target.access?.[action];
|
|
1343
|
+
if (accessExpr === void 0 || accessExpr === null) {
|
|
1344
|
+
return await next();
|
|
1345
|
+
}
|
|
1346
|
+
const accessArgs = { user, req: c.req, doc: null };
|
|
1347
|
+
const allowed = await evaluateAccess(accessExpr, accessArgs);
|
|
1348
|
+
if (!allowed) {
|
|
1349
|
+
return c.json({ error: true, message: `Access denied: ${action} on ${target.slug}` }, 403);
|
|
1350
|
+
}
|
|
1351
|
+
await next();
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
function registerRoutes(app, config2) {
|
|
1355
|
+
app.get("/api/schemas", optionalAuth(), async (c) => {
|
|
1356
|
+
const siteId = c.req.header("X-Site-Id");
|
|
1357
|
+
let collections = [...config2.collections];
|
|
1358
|
+
let globals = [...config2.globals];
|
|
1359
|
+
if (siteId && config2.onSchemaFetch) {
|
|
1360
|
+
const dynamic = await config2.onSchemaFetch(siteId);
|
|
1361
|
+
if (dynamic.collections) collections = [...collections, ...dynamic.collections];
|
|
1362
|
+
if (dynamic.globals) globals = [...globals, ...dynamic.globals];
|
|
1363
|
+
if (dynamic.admin) {
|
|
1364
|
+
config2.admin = { ...config2.admin, ...dynamic.admin };
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
const user = c.get("user");
|
|
1368
|
+
const accessArgs = { user, req: c.req, doc: null };
|
|
1369
|
+
const resolveAccess = async (access) => {
|
|
1370
|
+
if (access === void 0 || access === null) return true;
|
|
1371
|
+
if (typeof access === "function") {
|
|
1372
|
+
try {
|
|
1373
|
+
const result = await access(accessArgs);
|
|
1374
|
+
return typeof result === "boolean" ? result : !!result;
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
console.error("[dyrected/core] Functional access check failed:", err);
|
|
1377
|
+
return false;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (typeof access === "string" || typeof access === "boolean") {
|
|
1381
|
+
return evaluateAccess(access, accessArgs);
|
|
1382
|
+
}
|
|
1383
|
+
return true;
|
|
1384
|
+
};
|
|
1385
|
+
const filteredCollections = await Promise.all(collections.filter((col) => !siteId || col.shared || !col.siteId || col.siteId === siteId).map(async (col) => ({
|
|
1386
|
+
slug: col.slug,
|
|
1387
|
+
labels: col.labels,
|
|
1388
|
+
access: {
|
|
1389
|
+
read: await resolveAccess(col.access?.read),
|
|
1390
|
+
create: await resolveAccess(col.access?.create),
|
|
1391
|
+
update: await resolveAccess(col.access?.update),
|
|
1392
|
+
delete: await resolveAccess(col.access?.delete)
|
|
1393
|
+
},
|
|
1394
|
+
fields: await Promise.all(col.fields.map(async (f) => ({
|
|
1395
|
+
name: f.name,
|
|
1396
|
+
type: f.type,
|
|
1397
|
+
label: f.label,
|
|
1398
|
+
required: f.required,
|
|
1399
|
+
defaultValue: f.defaultValue,
|
|
1400
|
+
options: f.options,
|
|
1401
|
+
relationTo: f.relationTo,
|
|
1402
|
+
hasMany: f.hasMany,
|
|
1403
|
+
fields: f.fields,
|
|
1404
|
+
blocks: f.blocks,
|
|
1405
|
+
admin: f.admin,
|
|
1406
|
+
access: {
|
|
1407
|
+
read: await resolveAccess(f.access?.read),
|
|
1408
|
+
update: await resolveAccess(f.access?.update)
|
|
1409
|
+
}
|
|
1410
|
+
}))),
|
|
1411
|
+
upload: !!col.upload,
|
|
1412
|
+
auth: !!col.auth,
|
|
1413
|
+
admin: col.admin
|
|
1414
|
+
})));
|
|
1415
|
+
const filteredGlobals = await Promise.all(globals.filter((glb) => !siteId || glb.shared || !glb.siteId || glb.siteId === siteId).map(async (glb) => ({
|
|
1416
|
+
slug: glb.slug,
|
|
1417
|
+
label: glb.label,
|
|
1418
|
+
access: {
|
|
1419
|
+
read: await resolveAccess(glb.access?.read),
|
|
1420
|
+
update: await resolveAccess(glb.access?.update)
|
|
1421
|
+
},
|
|
1422
|
+
fields: await Promise.all(glb.fields.map(async (f) => ({
|
|
1423
|
+
name: f.name,
|
|
1424
|
+
type: f.type,
|
|
1425
|
+
label: f.label,
|
|
1426
|
+
required: f.required,
|
|
1427
|
+
defaultValue: f.defaultValue,
|
|
1428
|
+
options: f.options,
|
|
1429
|
+
relationTo: f.relationTo,
|
|
1430
|
+
hasMany: f.hasMany,
|
|
1431
|
+
fields: f.fields,
|
|
1432
|
+
blocks: f.blocks,
|
|
1433
|
+
admin: f.admin,
|
|
1434
|
+
access: {
|
|
1435
|
+
read: await resolveAccess(f.access?.read),
|
|
1436
|
+
update: await resolveAccess(f.access?.update)
|
|
1437
|
+
}
|
|
1438
|
+
}))),
|
|
1439
|
+
admin: glb.admin
|
|
1440
|
+
})));
|
|
240
1441
|
return c.json({
|
|
241
|
-
collections:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
fields: col.fields,
|
|
245
|
-
auth: col.auth,
|
|
246
|
-
upload: col.upload
|
|
247
|
-
})),
|
|
248
|
-
globals: config.globals.map((glb) => ({
|
|
249
|
-
slug: glb.slug,
|
|
250
|
-
label: glb.label,
|
|
251
|
-
fields: glb.fields
|
|
252
|
-
}))
|
|
1442
|
+
collections: filteredCollections,
|
|
1443
|
+
globals: filteredGlobals,
|
|
1444
|
+
admin: config2.admin || {}
|
|
253
1445
|
});
|
|
254
1446
|
});
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
1447
|
+
app.get("/api/openapi.json", (c) => {
|
|
1448
|
+
return c.json(generateOpenApi(config2));
|
|
1449
|
+
});
|
|
1450
|
+
app.get("/api/docs", (c) => {
|
|
1451
|
+
return c.html(getSwaggerHtml());
|
|
1452
|
+
});
|
|
1453
|
+
app.get("/api/media/:filename{.+$}", async (c) => {
|
|
1454
|
+
const mediaController = new MediaController("media");
|
|
1455
|
+
return mediaController.serve(c);
|
|
1456
|
+
});
|
|
1457
|
+
app.get("/media/:filename{.+$}", async (c) => {
|
|
1458
|
+
const mediaController = new MediaController("media");
|
|
1459
|
+
return mediaController.serve(c);
|
|
1460
|
+
});
|
|
1461
|
+
if (config2.storage) {
|
|
1462
|
+
const uploadCollections = config2.collections.filter((c) => c.upload);
|
|
1463
|
+
for (const col of uploadCollections) {
|
|
1464
|
+
const mediaController = new MediaController(col.slug);
|
|
1465
|
+
const prefix = `/api/collections/${col.slug}`;
|
|
1466
|
+
app.get(`${prefix}/media`, accessGate(col, "read"), (c) => mediaController.find(c));
|
|
1467
|
+
app.get(`${prefix}/media/:filename{.+$}`, (c) => mediaController.serve(c));
|
|
1468
|
+
app.post(`${prefix}/media`, accessGate(col, "create"), (c) => mediaController.upload(c));
|
|
1469
|
+
app.delete(`${prefix}/media/:id`, accessGate(col, "delete"), (c) => mediaController.delete(c));
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
for (const collection of config2.collections) {
|
|
1473
|
+
if (!collection.auth) continue;
|
|
1474
|
+
const path = `/api/collections/${collection.slug}`;
|
|
1475
|
+
const authController = new AuthController(collection);
|
|
1476
|
+
app.post(`${path}/login`, (c) => authController.login(c));
|
|
1477
|
+
app.post(`${path}/logout`, (c) => authController.logout(c));
|
|
1478
|
+
app.get(`${path}/init`, (c) => authController.init(c));
|
|
1479
|
+
app.post(`${path}/first-user`, (c) => authController.registerFirstUser(c));
|
|
1480
|
+
app.get(`${path}/me`, requireAuth(), (c) => authController.me(c));
|
|
1481
|
+
app.post(`${path}/refresh-token`, requireAuth(), (c) => authController.refreshToken(c));
|
|
1482
|
+
app.post(`${path}/forgot-password`, (c) => authController.forgotPassword(c));
|
|
1483
|
+
app.post(`${path}/reset-password`, (c) => authController.resetPassword(c));
|
|
1484
|
+
app.post(`${path}/invite`, requireAuth(), (c) => authController.invite(c));
|
|
1485
|
+
app.post(`${path}/accept-invite`, (c) => authController.acceptInvite(c));
|
|
260
1486
|
}
|
|
261
|
-
for (const collection of
|
|
1487
|
+
for (const collection of config2.collections) {
|
|
262
1488
|
const path = `/api/collections/${collection.slug}`;
|
|
263
1489
|
const controller = new CollectionController(collection);
|
|
264
|
-
app.get(path, (c) => controller.find(c));
|
|
265
|
-
app.post(path, (c) => controller.create(c));
|
|
266
|
-
app.
|
|
267
|
-
app.
|
|
268
|
-
app.
|
|
1490
|
+
app.get(path, accessGate(collection, "read"), (c) => controller.find(c));
|
|
1491
|
+
app.post(path, accessGate(collection, "create"), (c) => controller.create(c));
|
|
1492
|
+
app.post(`${path}/media`, accessGate(collection, "create"), (c) => controller.create(c));
|
|
1493
|
+
app.get(`${path}/:id`, accessGate(collection, "read"), (c) => controller.findOne(c));
|
|
1494
|
+
app.patch(`${path}/:id`, accessGate(collection, "update"), (c) => controller.update(c));
|
|
1495
|
+
app.delete(`${path}/:id`, accessGate(collection, "delete"), (c) => controller.delete(c));
|
|
1496
|
+
app.post(`${path}/seed`, (c) => controller.seed(c));
|
|
269
1497
|
}
|
|
270
|
-
for (const global of
|
|
1498
|
+
for (const global of config2.globals) {
|
|
271
1499
|
const path = `/api/globals/${global.slug}`;
|
|
272
1500
|
const controller = new GlobalController(global);
|
|
273
|
-
app.get(path, (c) => controller.get(c));
|
|
274
|
-
app.patch(path, (c) => controller.update(c));
|
|
1501
|
+
app.get(path, accessGate(global, "read"), (c) => controller.get(c));
|
|
1502
|
+
app.patch(path, accessGate(global, "update"), (c) => controller.update(c));
|
|
1503
|
+
app.post(`${path}/seed`, (c) => controller.seed(c));
|
|
275
1504
|
}
|
|
1505
|
+
const previewController = new PreviewController();
|
|
1506
|
+
app.post("/api/preview-token", requireAuth(), (c) => previewController.createToken(c));
|
|
1507
|
+
app.get("/api/preview-data", (c) => previewController.getData(c));
|
|
1508
|
+
app.all("/api/collections/:slug/:id?", async (c) => {
|
|
1509
|
+
const slug = c.req.param("slug");
|
|
1510
|
+
const id = c.req.param("id");
|
|
1511
|
+
const siteId = c.req.header("X-Site-Id") || c.get("siteId");
|
|
1512
|
+
const config3 = c.get("config");
|
|
1513
|
+
if (config3.collections.some((col) => col.slug === slug)) {
|
|
1514
|
+
return c.json({ message: "Method Not Allowed" }, 405);
|
|
1515
|
+
}
|
|
1516
|
+
if (config3.onSchemaFetch && siteId) {
|
|
1517
|
+
const dynamic = await config3.onSchemaFetch(siteId);
|
|
1518
|
+
let collection = dynamic.collections?.find((col) => col.slug === slug);
|
|
1519
|
+
if (!collection && slug === "media") {
|
|
1520
|
+
collection = {
|
|
1521
|
+
slug: "media",
|
|
1522
|
+
labels: { singular: "Media", plural: "Media" },
|
|
1523
|
+
upload: true,
|
|
1524
|
+
fields: []
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
if (collection) {
|
|
1528
|
+
if (collection.auth && id) {
|
|
1529
|
+
const authController = new AuthController(collection);
|
|
1530
|
+
const method2 = c.req.method;
|
|
1531
|
+
if (method2 === "POST" && id === "login") return authController.login(c);
|
|
1532
|
+
if (method2 === "POST" && id === "logout") return authController.logout(c);
|
|
1533
|
+
if (method2 === "GET" && id === "me") return authController.me(c);
|
|
1534
|
+
if (method2 === "POST" && id === "refresh-token") return authController.refreshToken(c);
|
|
1535
|
+
if (method2 === "POST" && id === "forgot-password") return authController.forgotPassword(c);
|
|
1536
|
+
if (method2 === "POST" && id === "reset-password") return authController.resetPassword(c);
|
|
1537
|
+
}
|
|
1538
|
+
const controller = new CollectionController(collection);
|
|
1539
|
+
const method = c.req.method;
|
|
1540
|
+
if (id) {
|
|
1541
|
+
if (method === "GET") return controller.findOne(c);
|
|
1542
|
+
if (method === "PATCH") return controller.update(c);
|
|
1543
|
+
if (method === "DELETE") return controller.delete(c);
|
|
1544
|
+
if (method === "POST" && id === "media") return controller.create(c);
|
|
1545
|
+
} else {
|
|
1546
|
+
if (method === "GET") return controller.find(c);
|
|
1547
|
+
if (method === "POST") return controller.create(c);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
return c.json({ message: `Collection "${slug}" not found` }, 404);
|
|
1552
|
+
});
|
|
1553
|
+
app.all("/api/globals/:slug", async (c) => {
|
|
1554
|
+
const slug = c.req.param("slug");
|
|
1555
|
+
const siteId = c.req.header("X-Site-Id") || c.get("siteId");
|
|
1556
|
+
const config3 = c.get("config");
|
|
1557
|
+
if (config3.globals.some((glb) => glb.slug === slug)) {
|
|
1558
|
+
return c.json({ message: "Method Not Allowed" }, 405);
|
|
1559
|
+
}
|
|
1560
|
+
if (config3.onSchemaFetch && siteId) {
|
|
1561
|
+
const dynamic = await config3.onSchemaFetch(siteId);
|
|
1562
|
+
const global = dynamic.globals?.find((glb) => glb.slug === slug);
|
|
1563
|
+
if (global) {
|
|
1564
|
+
const controller = new GlobalController(global);
|
|
1565
|
+
if (c.req.method === "GET") return controller.get(c);
|
|
1566
|
+
if (c.req.method === "PATCH") return controller.update(c);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
return c.json({ message: `Global "${slug}" not found` }, 404);
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// src/utils/config.ts
|
|
1574
|
+
function normalizeConfig(config2) {
|
|
1575
|
+
const normalizedCollections = config2.collections.map((col) => {
|
|
1576
|
+
const useTimestamps = col.timestamps !== false;
|
|
1577
|
+
if (!useTimestamps) return col;
|
|
1578
|
+
const timestampFields = [
|
|
1579
|
+
{
|
|
1580
|
+
name: "createdAt",
|
|
1581
|
+
type: "date",
|
|
1582
|
+
label: "Created At",
|
|
1583
|
+
admin: {
|
|
1584
|
+
readOnly: true,
|
|
1585
|
+
hidden: false,
|
|
1586
|
+
description: "The date this record was created."
|
|
1587
|
+
}
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
name: "updatedAt",
|
|
1591
|
+
type: "date",
|
|
1592
|
+
label: "Updated At",
|
|
1593
|
+
admin: {
|
|
1594
|
+
readOnly: true,
|
|
1595
|
+
hidden: false,
|
|
1596
|
+
description: "The date this record was last updated."
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
];
|
|
1600
|
+
const existingFieldNames = col.fields.map((f) => f.name);
|
|
1601
|
+
const fieldsToInject = timestampFields.filter((f) => !existingFieldNames.includes(f.name));
|
|
1602
|
+
return {
|
|
1603
|
+
...col,
|
|
1604
|
+
fields: [...col.fields, ...fieldsToInject]
|
|
1605
|
+
};
|
|
1606
|
+
});
|
|
1607
|
+
return {
|
|
1608
|
+
...config2,
|
|
1609
|
+
collections: normalizedCollections
|
|
1610
|
+
};
|
|
276
1611
|
}
|
|
277
1612
|
|
|
278
1613
|
// src/app.ts
|
|
279
|
-
function createDyrectedApp(
|
|
1614
|
+
async function createDyrectedApp(rawConfig) {
|
|
1615
|
+
const config2 = normalizeConfig(rawConfig);
|
|
280
1616
|
const app = new Hono();
|
|
1617
|
+
if (config2.db?.sync) {
|
|
1618
|
+
await config2.db.sync(config2.collections, config2.globals);
|
|
1619
|
+
}
|
|
281
1620
|
app.use("*", requestId());
|
|
282
|
-
app.use("*",
|
|
1621
|
+
app.use("*", async (c, next) => {
|
|
1622
|
+
const start = Date.now();
|
|
1623
|
+
await next();
|
|
1624
|
+
const ms = Date.now() - start;
|
|
1625
|
+
console.log(`[dyrected/api] ${c.req.method} ${c.req.path} ${c.res.status} - ${ms}ms`);
|
|
1626
|
+
});
|
|
283
1627
|
app.use("*", cors());
|
|
284
1628
|
app.use("*", async (c, next) => {
|
|
285
|
-
c.set("config",
|
|
1629
|
+
c.set("config", config2);
|
|
286
1630
|
if (!c.get("siteId")) {
|
|
287
1631
|
c.set("siteId", "default");
|
|
288
1632
|
}
|
|
289
1633
|
await next();
|
|
290
1634
|
});
|
|
291
1635
|
app.get("/health", (c) => c.json({ status: "ok", version: "0.0.1" }));
|
|
292
|
-
|
|
1636
|
+
app.get("/routes", (c) => {
|
|
1637
|
+
const routes = app.routes.map((r) => ({ method: r.method, path: r.path }));
|
|
1638
|
+
return c.json({ routes });
|
|
1639
|
+
});
|
|
1640
|
+
app.onError((err, c) => {
|
|
1641
|
+
console.error(`[dyrected/core] Uncaught Error:`, err);
|
|
1642
|
+
return c.json({
|
|
1643
|
+
message: err.message || "Internal Server Error",
|
|
1644
|
+
stack: process.env.NODE_ENV === "development" ? err.stack : void 0
|
|
1645
|
+
}, 500);
|
|
1646
|
+
});
|
|
1647
|
+
registerRoutes(app, config2);
|
|
293
1648
|
return app;
|
|
294
1649
|
}
|
|
295
1650
|
|
|
@@ -339,15 +1694,274 @@ var MediaService = class {
|
|
|
339
1694
|
}
|
|
340
1695
|
};
|
|
341
1696
|
|
|
1697
|
+
// src/utils/setup-prompt.ts
|
|
1698
|
+
function generateAIPrompt(activeTab, config2) {
|
|
1699
|
+
const frameworkLabel = activeTab === "next" ? "Next.js" : activeTab === "nuxt" ? "Nuxt" : activeTab.charAt(0).toUpperCase() + activeTab.slice(1);
|
|
1700
|
+
const backendPkg = activeTab === "nuxt" ? "@dyrected/nuxt" : "@dyrected/next";
|
|
1701
|
+
const isSelfHosted = config2.isSelfHosted ?? (config2.baseUrl?.includes("localhost") || !config2.apiKey);
|
|
1702
|
+
const baseIntro = isSelfHosted ? `You are a Senior Content Architect. Your mission is to integrate Dyrected CMS into a ${config2.siteName || "new"} project using ${frameworkLabel}. This is a SELF-HOSTED installation.
|
|
1703
|
+
The backend is already configured via ${backendPkg}.
|
|
1704
|
+
Your priority is DATA PRESERVATION and creating a CMS that empowers marketing teams.` : `You are a Senior Content Architect. Your mission is to integrate Dyrected CMS into a ${config2.siteName || "new"} project using ${frameworkLabel}. Complete the entire setup automatically, prioritizing DATA PRESERVATION and marketing independence.`;
|
|
1705
|
+
const credentials = `
|
|
1706
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1707
|
+
SITE CREDENTIALS
|
|
1708
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1709
|
+
${config2.siteName ? `Site Name : ${config2.siteName}
|
|
1710
|
+
` : ""}${isSelfHosted ? "" : `Site ID : ${config2.siteId}
|
|
1711
|
+
API Key : ${config2.apiKey}
|
|
1712
|
+
`}API URL : ${config2.baseUrl || "http://localhost:3000"}
|
|
1713
|
+
`;
|
|
1714
|
+
const importantNotes = `
|
|
1715
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1716
|
+
IMPORTANT NOTES \u2014 READ BEFORE ACTING
|
|
1717
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1718
|
+
- \`client.collection(slug)\` is the primary API entrypoint. Do NOT use \`client.collections\`.
|
|
1719
|
+
- Blocks are stored as \`[{ blockType: '<slug>', ...fields }]\` \u2014 always switch on \`blockType\` when rendering.
|
|
1720
|
+
- MARKETING INDEPENDENCE: Always use a dynamic \`pages\` collection with a catch-all route. Marketing should create pages without a developer. (Exceptions: Special pages like Contact or those with complex forms may use static routes).
|
|
1721
|
+
- AUTO-SEEDING: Use \`initialData\` in all data fetches to ensure the site is never empty on first load.
|
|
1722
|
+
- Globals use \`client.global(slug).get()\` and \`client.global(slug).update(data)\`.
|
|
1723
|
+
- Relationship fields are populated to the specified \`depth\` (default: 1). Set \`depth: 0\` for IDs only.
|
|
1724
|
+
`;
|
|
1725
|
+
const strategy = `
|
|
1726
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1727
|
+
PHASE 0 \u2014 DATA PRESERVATION & STRATEGY
|
|
1728
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1729
|
+
1. BACKUP: If an existing site is provided, you MUST extract all current content (text, assets, structure) and save it into a \`migration/\` folder as structured .md files BEFORE modifying any code. Never lose data.
|
|
1730
|
+
2. DISCOVERY: If NO existing site is provided, STOP and ask the user:
|
|
1731
|
+
- "What are your core content types (e.g. Services, Team, Blog)?"
|
|
1732
|
+
- "How do you want your marketing team to manage the page layouts?"
|
|
1733
|
+
3. ARCHITECTURAL CREATIVITY: Design the CMS for longevity. Use \`blocks\` for flexible page builders, \`globals\` for site settings, and \`collections\` for repeated content.
|
|
1734
|
+
|
|
1735
|
+
STEP 1 \u2014 CONTENT MODEL (dyrected.config.ts)
|
|
1736
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1737
|
+
Use \`defineCollection\`, \`defineGlobal\`, and \`defineConfig\` from '@dyrected/core'.
|
|
1738
|
+
|
|
1739
|
+
SUPPORTED FIELD TYPES:
|
|
1740
|
+
Primitive : text | textarea | richText | number | boolean | date | email | url | json
|
|
1741
|
+
Choice : select | multiSelect (requires \`options: [{ label, value }]\`)
|
|
1742
|
+
Structural : array | object (requires nested \`fields: [...]\`)
|
|
1743
|
+
Relation : relationship (requires \`collection: '<slug>'\`)
|
|
1744
|
+
Media : image (use a relationship to an upload collection)
|
|
1745
|
+
Blocks : blocks (requires \`blocks: [{ slug, labels, fields }]\`)
|
|
1746
|
+
|
|
1747
|
+
COLLECTION OPTIONS:
|
|
1748
|
+
\`upload: true\` \u2014 turns this collection into a media library (file uploads)
|
|
1749
|
+
\`auth: true\` \u2014 adds login/register/me endpoints; password field is auto-added
|
|
1750
|
+
\`admin.group\` \u2014 groups this collection under a sidebar heading
|
|
1751
|
+
\`admin.useAsTitle\` \u2014 field to use as the display title in the admin list view
|
|
1752
|
+
\`admin.hidden\` \u2014 hide from sidebar (useful for internal/system collections)
|
|
1753
|
+
|
|
1754
|
+
FIELD OPTIONS:
|
|
1755
|
+
\`required\` \u2014 validation
|
|
1756
|
+
\`unique\` \u2014 database-level uniqueness
|
|
1757
|
+
\`defaultValue\` \u2014 fallback value
|
|
1758
|
+
\`admin.condition\` \u2014 "expression" \u2014 Jexl string expression to show/hide field (e.g. "status == \\"published\\"")
|
|
1759
|
+
\`admin.layout\` \u2014 "radio" | "dropdown" \u2014 Visual layout for select/multiSelect
|
|
1760
|
+
\`admin.direction\` \u2014 "vertical" | "horizontal" \u2014 Layout direction for radio groups
|
|
1761
|
+
\`admin.readOnly\` \u2014 display-only in the form
|
|
1762
|
+
\`admin.hidden\` \u2014 completely hidden from editor UI
|
|
1763
|
+
\`access.read\` \u2014 ({ user }) => boolean \u2014 field-level read access
|
|
1764
|
+
\`access.update\` \u2014 ({ user }) => boolean \u2014 field-level write access
|
|
1765
|
+
\`hooks.beforeChange\` \u2014 [async (value) => newValue] \u2014 transform value before save
|
|
1766
|
+
\`hooks.afterRead\` \u2014 [async (value) => newValue] \u2014 transform value after read
|
|
1767
|
+
|
|
1768
|
+
BLOCKS EXPLAINED:
|
|
1769
|
+
A \`blocks\` field stores an ordered array of typed content blocks.
|
|
1770
|
+
Each block has a \`blockType\` discriminator and its own set of fields.
|
|
1771
|
+
The admin UI renders a drag-and-drop block editor automatically.
|
|
1772
|
+
On the frontend, iterate the array and switch on \`block.blockType\`.
|
|
1773
|
+
|
|
1774
|
+
COMPLETE EXAMPLE:
|
|
1775
|
+
\`\`\`typescript
|
|
1776
|
+
import { defineCollection, defineGlobal, defineConfig } from '@dyrected/core'
|
|
1777
|
+
|
|
1778
|
+
// \u2500\u2500 Media \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1779
|
+
const media = defineCollection({
|
|
1780
|
+
slug: 'media',
|
|
1781
|
+
labels: { singular: 'Media Item', plural: 'Media' },
|
|
1782
|
+
upload: true,
|
|
1783
|
+
fields: [
|
|
1784
|
+
{ name: 'alt', type: 'text', label: 'Alt Text' },
|
|
1785
|
+
{ name: 'caption', type: 'textarea', label: 'Caption' },
|
|
1786
|
+
],
|
|
1787
|
+
})
|
|
1788
|
+
|
|
1789
|
+
// \u2500\u2500 Authentication collection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1790
|
+
const customers = defineCollection({
|
|
1791
|
+
slug: 'customers',
|
|
1792
|
+
labels: { singular: 'Customer', plural: 'Customers' },
|
|
1793
|
+
auth: true, // adds /customers/login, /customers/me, etc.
|
|
1794
|
+
admin: { group: 'Membership' },
|
|
1795
|
+
fields: [
|
|
1796
|
+
{ name: 'name', type: 'text', required: true },
|
|
1797
|
+
{ name: 'email', type: 'email', required: true, unique: true },
|
|
1798
|
+
// 'password' is auto-added when auth: true
|
|
1799
|
+
{ name: 'avatar', type: 'relationship', relationTo: 'media' },
|
|
1800
|
+
{ name: 'role', type: 'select', admin: { layout: 'radio' }, options: [
|
|
1801
|
+
{ label: 'Member', value: 'member' },
|
|
1802
|
+
{ label: 'VIP', value: 'vip' },
|
|
1803
|
+
]},
|
|
1804
|
+
],
|
|
1805
|
+
})
|
|
1806
|
+
|
|
1807
|
+
// \u2500\u2500 Pages with blocks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1808
|
+
const pages = defineCollection({
|
|
1809
|
+
slug: 'pages',
|
|
1810
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
1811
|
+
admin: { useAsTitle: 'title', group: 'Content' },
|
|
1812
|
+
fields: [
|
|
1813
|
+
{ name: 'title', type: 'text', required: true },
|
|
1814
|
+
{ name: 'slug', type: 'text', required: true, unique: true },
|
|
1815
|
+
{ name: 'seo', type: 'object', fields: [
|
|
1816
|
+
{ name: 'metaTitle', type: 'text' },
|
|
1817
|
+
{ name: 'metaDescription', type: 'textarea' },
|
|
1818
|
+
{ name: 'ogImage', type: 'relationship', relationTo: 'media' },
|
|
1819
|
+
]},
|
|
1820
|
+
{
|
|
1821
|
+
name: 'layout',
|
|
1822
|
+
type: 'blocks',
|
|
1823
|
+
label: 'Page Layout',
|
|
1824
|
+
blocks: [
|
|
1825
|
+
{
|
|
1826
|
+
slug: 'hero',
|
|
1827
|
+
labels: { singular: 'Hero', plural: 'Heroes' },
|
|
1828
|
+
fields: [
|
|
1829
|
+
{ name: 'heading', type: 'text', required: true },
|
|
1830
|
+
{ name: 'subheading', type: 'textarea' },
|
|
1831
|
+
{ name: 'image', type: 'relationship', relationTo: 'media' },
|
|
1832
|
+
{ name: 'ctaLabel', type: 'text' },
|
|
1833
|
+
{ name: 'ctaLink', type: 'url' },
|
|
1834
|
+
],
|
|
1835
|
+
},
|
|
1836
|
+
{
|
|
1837
|
+
slug: 'richContent',
|
|
1838
|
+
labels: { singular: 'Rich Content', plural: 'Rich Content Blocks' },
|
|
1839
|
+
fields: [
|
|
1840
|
+
{ name: 'content', type: 'richText', required: true },
|
|
1841
|
+
],
|
|
1842
|
+
},
|
|
1843
|
+
],
|
|
1844
|
+
},
|
|
1845
|
+
],
|
|
1846
|
+
})
|
|
1847
|
+
|
|
1848
|
+
export default defineConfig({
|
|
1849
|
+
collections: [media, customers, pages],
|
|
1850
|
+
admin: {
|
|
1851
|
+
branding: {
|
|
1852
|
+
primaryColor: '#4f46e5',
|
|
1853
|
+
logo: '/logo.png',
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
})
|
|
1857
|
+
\`\`\`
|
|
1858
|
+
|
|
1859
|
+
${isSelfHosted ? "" : `
|
|
1860
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1861
|
+
STEP 2 \u2014 CHOOSE YOUR MODE
|
|
1862
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1863
|
+
The developer can choose between two modes:
|
|
1864
|
+
|
|
1865
|
+
1. CLOUD MODE (Managed)
|
|
1866
|
+
- Use the SITE CREDENTIALS above.
|
|
1867
|
+
- Point baseUrl to ${config2.baseUrl}.
|
|
1868
|
+
- Content is stored in Dyrected Cloud.
|
|
1869
|
+
|
|
1870
|
+
2. SELF-HOSTED MODE (Core)
|
|
1871
|
+
- Do NOT use apiKey/siteId (unless for proxying).
|
|
1872
|
+
- Use a database adapter like \`SqliteAdapter\` from '@dyrected/db-sqlite'.
|
|
1873
|
+
- Content is stored locally in the developer's project.
|
|
1874
|
+
`}
|
|
1875
|
+
|
|
1876
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1877
|
+
STEP ${isSelfHosted ? "2" : "3"} \u2014 MOUNTING THE ADMIN UI
|
|
1878
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1879
|
+
The Admin UI can be mounted on any path (e.g. /cms-admin).
|
|
1880
|
+
Pass the \`basename\` prop to the \`<AdminUI />\` component to match your route.
|
|
1881
|
+
|
|
1882
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1883
|
+
STEP ${isSelfHosted ? "3" : "4"} \u2014 FRONTEND IMPLEMENTATION
|
|
1884
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
|
|
1885
|
+
`;
|
|
1886
|
+
const frameworks = {
|
|
1887
|
+
next: `Install \`@dyrected/sdk\` (or \`@dyrected/next\` if you want Next.js server helpers).
|
|
1888
|
+
|
|
1889
|
+
SDK CLIENT SETUP (\`lib/dyrected.ts\`):
|
|
1890
|
+
\`\`\`ts
|
|
1891
|
+
import { createClient } from '@dyrected/sdk'
|
|
1892
|
+
|
|
1893
|
+
export const dyrected = createClient({
|
|
1894
|
+
baseUrl: '${config2.baseUrl || "http://localhost:3000"}',${isSelfHosted ? "" : `
|
|
1895
|
+
apiKey: '${config2.apiKey}',
|
|
1896
|
+
siteId: '${config2.siteId}',`}
|
|
1897
|
+
})
|
|
1898
|
+
\`\`\``,
|
|
1899
|
+
nuxt: `Install \`@dyrected/nuxt\` and add it to \`nuxt.config.ts\`:
|
|
1900
|
+
\`\`\`ts
|
|
1901
|
+
export default defineNuxtConfig({
|
|
1902
|
+
modules: ['@dyrected/nuxt'],
|
|
1903
|
+
dyrected: {
|
|
1904
|
+
baseUrl: '${config2.baseUrl || "http://localhost:3000"}',${isSelfHosted ? "" : `
|
|
1905
|
+
apiKey: '${config2.apiKey}',
|
|
1906
|
+
siteId: '${config2.siteId}',`}
|
|
1907
|
+
},
|
|
1908
|
+
})
|
|
1909
|
+
\`\`\`
|
|
1910
|
+
|
|
1911
|
+
MOUNTING THE ADMIN DASHBOARD (\`pages/cms-admin.vue\`):
|
|
1912
|
+
\`\`\`vue
|
|
1913
|
+
<script setup lang="ts">
|
|
1914
|
+
definePageMeta({ layout: false })
|
|
1915
|
+
</script>
|
|
1916
|
+
|
|
1917
|
+
<template>
|
|
1918
|
+
<ClientOnly>
|
|
1919
|
+
<DyrectedAdmin basename="/cms-admin" />
|
|
1920
|
+
</ClientOnly>
|
|
1921
|
+
</template>
|
|
1922
|
+
\`\`\`
|
|
1923
|
+
`,
|
|
1924
|
+
react: `Install \`@dyrected/sdk\`:
|
|
1925
|
+
|
|
1926
|
+
CLIENT SETUP (\`lib/dyrected.ts\`):
|
|
1927
|
+
\`\`\`ts
|
|
1928
|
+
import { createClient } from '@dyrected/sdk'
|
|
1929
|
+
|
|
1930
|
+
export const dyrected = createClient({
|
|
1931
|
+
baseUrl: '${config2.baseUrl || "http://localhost:3000"}',${isSelfHosted ? "" : `
|
|
1932
|
+
apiKey: '${config2.apiKey}',
|
|
1933
|
+
siteId: '${config2.siteId}',`}
|
|
1934
|
+
})
|
|
1935
|
+
\`\`\`
|
|
1936
|
+
`,
|
|
1937
|
+
vue: `Install \`@dyrected/sdk\`:
|
|
1938
|
+
|
|
1939
|
+
CLIENT SETUP (\`lib/dyrected.ts\`):
|
|
1940
|
+
\`\`\`ts
|
|
1941
|
+
import { createClient } from '@dyrected/sdk'
|
|
1942
|
+
|
|
1943
|
+
export const dyrected = createClient({
|
|
1944
|
+
baseUrl: '${config2.baseUrl || "http://localhost:3000"}',${isSelfHosted ? "" : `
|
|
1945
|
+
apiKey: '${config2.apiKey}',
|
|
1946
|
+
siteId: '${config2.siteId}',`}
|
|
1947
|
+
})
|
|
1948
|
+
\`\`\`
|
|
1949
|
+
`
|
|
1950
|
+
};
|
|
1951
|
+
return baseIntro + credentials + importantNotes + strategy + (frameworks[activeTab] || frameworks.next) + `
|
|
1952
|
+
|
|
1953
|
+
API Reference: ${config2.baseUrl || "http://localhost:3000"}/api/docs`;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
342
1956
|
// src/index.ts
|
|
343
|
-
function defineCollection(
|
|
344
|
-
return
|
|
1957
|
+
function defineCollection(config2) {
|
|
1958
|
+
return config2;
|
|
345
1959
|
}
|
|
346
|
-
function defineGlobal(
|
|
347
|
-
return
|
|
1960
|
+
function defineGlobal(config2) {
|
|
1961
|
+
return config2;
|
|
348
1962
|
}
|
|
349
|
-
function defineConfig(
|
|
350
|
-
return
|
|
1963
|
+
function defineConfig(config2) {
|
|
1964
|
+
return config2;
|
|
351
1965
|
}
|
|
352
1966
|
export {
|
|
353
1967
|
MediaService,
|
|
@@ -355,5 +1969,6 @@ export {
|
|
|
355
1969
|
createDyrectedApp,
|
|
356
1970
|
defineCollection,
|
|
357
1971
|
defineConfig,
|
|
358
|
-
defineGlobal
|
|
1972
|
+
defineGlobal,
|
|
1973
|
+
generateAIPrompt
|
|
359
1974
|
};
|