@adminforge/core 0.3.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/.turbo/turbo-build.log +56 -0
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/bin/adminforge.js +317 -0
- package/dist/auth-client.cjs +45 -0
- package/dist/auth-client.cjs.map +1 -0
- package/dist/auth-client.d.cts +17 -0
- package/dist/auth-client.d.ts +17 -0
- package/dist/auth-client.js +20 -0
- package/dist/auth-client.js.map +1 -0
- package/dist/auth.cjs +65 -0
- package/dist/auth.cjs.map +1 -0
- package/dist/auth.d.cts +21 -0
- package/dist/auth.d.ts +21 -0
- package/dist/auth.js +36 -0
- package/dist/auth.js.map +1 -0
- package/dist/client-D0cjJVsn.d.ts +20 -0
- package/dist/client-sRnmZ-Y9.d.cts +20 -0
- package/dist/index-CyzxaE7n.d.cts +124 -0
- package/dist/index-CyzxaE7n.d.ts +124 -0
- package/dist/index.cjs +453 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +65 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.js +410 -0
- package/dist/index.js.map +1 -0
- package/dist/next.cjs +839 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +84 -0
- package/dist/next.d.ts +84 -0
- package/dist/next.js +800 -0
- package/dist/next.js.map +1 -0
- package/dist/styles.css +763 -0
- package/dist/styles.css.map +1 -0
- package/dist/styles.d.cts +2 -0
- package/dist/styles.d.ts +2 -0
- package/dist/ui.cjs +2500 -0
- package/dist/ui.cjs.map +1 -0
- package/dist/ui.d.cts +119 -0
- package/dist/ui.d.ts +119 -0
- package/dist/ui.js +2448 -0
- package/dist/ui.js.map +1 -0
- package/eslint.config.js +35 -0
- package/package.json +99 -0
- package/src/api/controller.ts +234 -0
- package/src/api/index.ts +4 -0
- package/src/api/next.ts +281 -0
- package/src/api/security/agent-auth.ts +134 -0
- package/src/auth/config.ts +20 -0
- package/src/auth/index.ts +3 -0
- package/src/auth/middleware.ts +15 -0
- package/src/auth/provider.tsx +28 -0
- package/src/core/fields/index.ts +119 -0
- package/src/core/hooks/index.ts +60 -0
- package/src/core/index.ts +43 -0
- package/src/core/registry/index.ts +22 -0
- package/src/core/schema/collection.ts +12 -0
- package/src/core/schema/config.ts +11 -0
- package/src/core/schema/normalize.ts +32 -0
- package/src/core/types/index.ts +114 -0
- package/src/db/client.ts +146 -0
- package/src/db/index.ts +3 -0
- package/src/db/schema-generator.ts +104 -0
- package/src/fields/index.ts +1 -0
- package/src/index.ts +4 -0
- package/src/next.ts +3 -0
- package/src/styles/adminforge.css +840 -0
- package/src/ui/AdminDashboard.tsx +176 -0
- package/src/ui/AdminForgeContext.tsx +64 -0
- package/src/ui/components/AdminLayout.tsx +107 -0
- package/src/ui/form-engine/FormEngine.tsx +250 -0
- package/src/ui/form-engine/ImageUpload.tsx +68 -0
- package/src/ui/form-engine/RelationInput.tsx +215 -0
- package/src/ui/form-engine/RichTextEditor.tsx +708 -0
- package/src/ui/index.ts +18 -0
- package/src/ui/screens/AdminPage.tsx +162 -0
- package/src/ui/screens/AgentTokenPage.tsx +232 -0
- package/src/ui/screens/CollectionFormPage.tsx +135 -0
- package/src/ui/screens/CollectionListPage.tsx +170 -0
- package/src/ui/screens/CollectionSchemaPage.tsx +180 -0
- package/src/ui/screens/RoleDetailPage.tsx +147 -0
- package/src/ui/screens/RolesListPage.tsx +57 -0
- package/src/ui/table-engine/TableEngine.tsx +157 -0
- package/src/ui.ts +3 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +54 -0
package/package.json
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adminforge/core",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/YuZaGa/AdminForge.git",
|
|
11
|
+
"directory": "packages/adminforge"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"adminforge": "./bin/adminforge.js"
|
|
16
|
+
},
|
|
17
|
+
"main": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js",
|
|
23
|
+
"require": "./dist/index.cjs"
|
|
24
|
+
},
|
|
25
|
+
"./ui": {
|
|
26
|
+
"types": "./dist/ui.d.ts",
|
|
27
|
+
"import": "./dist/ui.js",
|
|
28
|
+
"require": "./dist/ui.cjs"
|
|
29
|
+
},
|
|
30
|
+
"./next": {
|
|
31
|
+
"types": "./dist/next.d.ts",
|
|
32
|
+
"import": "./dist/next.js",
|
|
33
|
+
"require": "./dist/next.cjs"
|
|
34
|
+
},
|
|
35
|
+
"./auth": {
|
|
36
|
+
"types": "./dist/auth.d.ts",
|
|
37
|
+
"import": "./dist/auth.js",
|
|
38
|
+
"require": "./dist/auth.cjs"
|
|
39
|
+
},
|
|
40
|
+
"./auth-client": {
|
|
41
|
+
"types": "./dist/auth-client.d.ts",
|
|
42
|
+
"import": "./dist/auth-client.js",
|
|
43
|
+
"require": "./dist/auth-client.cjs"
|
|
44
|
+
},
|
|
45
|
+
"./styles": "./dist/styles.css"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@prisma/client": "^6.0.0",
|
|
49
|
+
"@tanstack/react-table": "^8.20.0",
|
|
50
|
+
"@tiptap/core": "3.23.1",
|
|
51
|
+
"@tiptap/extension-bubble-menu": "3.23.1",
|
|
52
|
+
"@tiptap/extension-floating-menu": "3.23.1",
|
|
53
|
+
"@tiptap/extension-highlight": "3.23.1",
|
|
54
|
+
"@tiptap/extension-horizontal-rule": "3.23.1",
|
|
55
|
+
"@tiptap/extension-image": "3.23.1",
|
|
56
|
+
"@tiptap/extension-link": "3.23.1",
|
|
57
|
+
"@tiptap/extension-placeholder": "3.23.1",
|
|
58
|
+
"@tiptap/extension-subscript": "3.23.1",
|
|
59
|
+
"@tiptap/extension-superscript": "3.23.1",
|
|
60
|
+
"@tiptap/extension-task-item": "3.23.1",
|
|
61
|
+
"@tiptap/extension-task-list": "3.23.1",
|
|
62
|
+
"@tiptap/extension-text-align": "3.23.1",
|
|
63
|
+
"@tiptap/extension-typography": "3.23.1",
|
|
64
|
+
"@tiptap/extension-underline": "3.23.1",
|
|
65
|
+
"@tiptap/pm": "3.23.1",
|
|
66
|
+
"@tiptap/react": "3.23.1",
|
|
67
|
+
"@tiptap/starter-kit": "3.23.1",
|
|
68
|
+
"jsonwebtoken": "^9.0.3",
|
|
69
|
+
"next": "^15.0.0",
|
|
70
|
+
"next-auth": "^5.0.0-beta.25",
|
|
71
|
+
"react": "^19.0.0",
|
|
72
|
+
"react-dom": "^19.0.0",
|
|
73
|
+
"zod": "^3.24.0"
|
|
74
|
+
},
|
|
75
|
+
"devDependencies": {
|
|
76
|
+
"@eslint/js": "^10.0.1",
|
|
77
|
+
"@types/jsonwebtoken": "^9.0.10",
|
|
78
|
+
"@types/node": "^22.10.1",
|
|
79
|
+
"@types/react": "^19.0.0",
|
|
80
|
+
"@types/react-dom": "^19.0.0",
|
|
81
|
+
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
|
82
|
+
"@typescript-eslint/parser": "^8.59.3",
|
|
83
|
+
"eslint": "^10.3.0",
|
|
84
|
+
"eslint-plugin-react": "^7.37.5",
|
|
85
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
86
|
+
"globals": "^17.6.0",
|
|
87
|
+
"prisma": "^6.0.0",
|
|
88
|
+
"tsup": "^8.3.0",
|
|
89
|
+
"typescript": "^5.7.0",
|
|
90
|
+
"typescript-eslint": "^8.59.3"
|
|
91
|
+
},
|
|
92
|
+
"scripts": {
|
|
93
|
+
"build": "tsup",
|
|
94
|
+
"dev": "DEV=true tsup --watch",
|
|
95
|
+
"lint": "eslint .",
|
|
96
|
+
"typecheck": "tsc --noEmit",
|
|
97
|
+
"clean": "rm -rf dist"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { AdminForgeConfig, CollectionDefinition, AccessConfig } from "../core";
|
|
2
|
+
import type { DbClient } from "../db";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { assertScope, type SecurityContext, type Action } from "./security/agent-auth";
|
|
5
|
+
|
|
6
|
+
function buildValidationSchema(collection: CollectionDefinition): z.ZodObject<Record<string, z.ZodTypeAny>> {
|
|
7
|
+
const shape: Record<string, z.ZodTypeAny> = {};
|
|
8
|
+
for (const [name, field] of Object.entries(collection.fields)) {
|
|
9
|
+
shape[name] = field.validation;
|
|
10
|
+
}
|
|
11
|
+
return z.object(shape);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface AdminSession {
|
|
15
|
+
user: { id: string; email: string; role?: string } | null;
|
|
16
|
+
role?: string; // Support for root-level role
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hasAccess(access: AccessConfig | undefined, operation: string, role?: string): boolean {
|
|
20
|
+
if (!access) return true;
|
|
21
|
+
const allowed = access[operation as keyof AccessConfig];
|
|
22
|
+
if (!allowed || !Array.isArray(allowed)) return true;
|
|
23
|
+
if (!role) return false;
|
|
24
|
+
return allowed.includes(role);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type AccessInfo = SecurityContext;
|
|
28
|
+
|
|
29
|
+
export interface Controller {
|
|
30
|
+
list: (args?: { where?: Record<string, unknown>; orderBy?: Record<string, string>; page?: number; pageSize?: number; search?: string }) => Promise<{ data: unknown[]; total: number; page: number; pageSize: number }>;
|
|
31
|
+
get: (id: string) => Promise<unknown | null>;
|
|
32
|
+
create: (data: Record<string, unknown>) => Promise<unknown>;
|
|
33
|
+
update: (id: string, data: Record<string, unknown>) => Promise<unknown>;
|
|
34
|
+
delete: (id: string) => Promise<unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createController(
|
|
38
|
+
collection: CollectionDefinition,
|
|
39
|
+
db: DbClient,
|
|
40
|
+
session?: SecurityContext,
|
|
41
|
+
): Controller {
|
|
42
|
+
const validationSchema = buildValidationSchema(collection);
|
|
43
|
+
const role = session?.user?.role || session?.agent?.role;
|
|
44
|
+
const actorId = session?.user?.id || session?.agent?.sub;
|
|
45
|
+
|
|
46
|
+
function requireAccess(operation: Action) {
|
|
47
|
+
console.error(`[ACL] Operation: ${operation}, Collection: ${collection.name}, Role: ${role}, Source: ${session?.source}`);
|
|
48
|
+
|
|
49
|
+
// 1. Scope Enforcement (limiter)
|
|
50
|
+
if (session?.agent) {
|
|
51
|
+
assertScope(session.agent, collection.name, operation);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 2. RBAC Enforcement (authority)
|
|
55
|
+
if (!hasAccess(collection.access, operation, role)) {
|
|
56
|
+
console.warn(`[ACL] DENIED: ${role} not allowed to ${operation} on ${collection.name}. Allowed:`, collection.access?.[operation]);
|
|
57
|
+
throw new Error(`Access denied: ${operation} on ${collection.name}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 3. Audit Logging
|
|
61
|
+
console.error(JSON.stringify({
|
|
62
|
+
timestamp: new Date().toISOString(),
|
|
63
|
+
type: "mutation_attempt",
|
|
64
|
+
source: session?.source || "unknown",
|
|
65
|
+
userId: actorId || "anonymous",
|
|
66
|
+
role: role || "none",
|
|
67
|
+
collection: collection.name,
|
|
68
|
+
action: operation,
|
|
69
|
+
sessionId: session?.agent?.sessionId,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildSearchWhere(search?: string): Record<string, unknown> | undefined {
|
|
74
|
+
if (!search) return undefined;
|
|
75
|
+
|
|
76
|
+
// Find all text-based fields to search in
|
|
77
|
+
const searchFields = Object.entries(collection.fields)
|
|
78
|
+
.filter(([_, field]) => field.type === "text" || field.type === "slug" || field.type === "richText")
|
|
79
|
+
.map(([name]) => name);
|
|
80
|
+
|
|
81
|
+
if (searchFields.length === 0) return undefined;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
OR: searchFields.map(field => ({
|
|
85
|
+
[field]: { contains: search, mode: 'insensitive' }
|
|
86
|
+
}))
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function filterFields(fields: Record<string, unknown>): Record<string, unknown> {
|
|
91
|
+
const filtered: Record<string, unknown> = {};
|
|
92
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
93
|
+
const field = collection.fields[key];
|
|
94
|
+
if (hasAccess(field?.access, "read", role)) {
|
|
95
|
+
filtered[key] = value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return filtered;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function filterWritableFields(
|
|
102
|
+
fields: Record<string, unknown>,
|
|
103
|
+
operation: "create" | "update"
|
|
104
|
+
): Record<string, unknown> {
|
|
105
|
+
const filtered: Record<string, unknown> = {};
|
|
106
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
107
|
+
const field = collection.fields[key];
|
|
108
|
+
if (field && hasAccess(field.access, operation, role)) {
|
|
109
|
+
filtered[key] = value;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return filtered;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function applyFieldHooks(
|
|
116
|
+
fields: Record<string, unknown>,
|
|
117
|
+
hookName: "beforeValidate" | "beforeSave"
|
|
118
|
+
): Record<string, unknown> {
|
|
119
|
+
const next = { ...fields };
|
|
120
|
+
for (const [key, value] of Object.entries(next)) {
|
|
121
|
+
const hook = collection.fields[key]?.hooks?.[hookName];
|
|
122
|
+
if (hook) {
|
|
123
|
+
next[key] = hook(value);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return next;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function applyDerivedFields(fields: Record<string, unknown>): Record<string, unknown> {
|
|
130
|
+
const next = { ...fields };
|
|
131
|
+
for (const [name, field] of Object.entries(collection.fields)) {
|
|
132
|
+
if (field.type !== "slug") continue;
|
|
133
|
+
const current = next[name];
|
|
134
|
+
if (typeof current === "string" && current.trim().length > 0) continue;
|
|
135
|
+
const from = field.ui.props?.from;
|
|
136
|
+
if (typeof from !== "string") continue;
|
|
137
|
+
const source = next[from];
|
|
138
|
+
if (typeof source !== "string" || source.trim().length === 0) continue;
|
|
139
|
+
const slugified = source
|
|
140
|
+
.toLowerCase()
|
|
141
|
+
.normalize("NFD")
|
|
142
|
+
.replace(/[\u0300-\u036f]/g, "") // Remove accents
|
|
143
|
+
.replace(/\s+/g, "-") // Replace spaces with -
|
|
144
|
+
.replace(/[^a-z0-9-]/g, "") // Remove non-alphanumeric
|
|
145
|
+
.replace(/-+/g, "-") // Remove double dashes
|
|
146
|
+
.replace(/^-+|-+$/g, ""); // Trim dashes
|
|
147
|
+
|
|
148
|
+
next[name] = slugified;
|
|
149
|
+
}
|
|
150
|
+
return next;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
async list(args = {}) {
|
|
155
|
+
requireAccess("read");
|
|
156
|
+
const { where, orderBy, page = 1, pageSize = 50, search } = args;
|
|
157
|
+
|
|
158
|
+
const searchWhere = buildSearchWhere(search);
|
|
159
|
+
const combinedWhere = searchWhere ? { ...where, ...searchWhere } : where;
|
|
160
|
+
|
|
161
|
+
const skip = (page - 1) * pageSize;
|
|
162
|
+
|
|
163
|
+
// Default sorting to newest first.
|
|
164
|
+
// We check for 'createdAt' but default to 'id' desc as a safe fallback for all DBs.
|
|
165
|
+
const effectiveOrderBy = orderBy || (collection.fields.createdAt ? { createdAt: 'desc' } : { id: 'desc' });
|
|
166
|
+
|
|
167
|
+
const [raw, total] = await Promise.all([
|
|
168
|
+
db.findMany(collection.name, { where: combinedWhere, orderBy: effectiveOrderBy, skip, take: pageSize }),
|
|
169
|
+
db.count(collection.name, { where: combinedWhere })
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
const data = (raw as Record<string, unknown>[]).map(filterFields);
|
|
173
|
+
return { data, total, page, pageSize };
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
async get(id: string) {
|
|
177
|
+
requireAccess("read");
|
|
178
|
+
const raw = await db.findUnique(collection.name, id);
|
|
179
|
+
if (!raw) return null;
|
|
180
|
+
return filterFields(raw as Record<string, unknown>);
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
async create(data: Record<string, unknown>) {
|
|
184
|
+
requireAccess("create");
|
|
185
|
+
const allowed = filterWritableFields(data, "create");
|
|
186
|
+
const withDerived = applyDerivedFields(allowed);
|
|
187
|
+
const beforeValidate = applyFieldHooks(withDerived, "beforeValidate");
|
|
188
|
+
const parsed = validationSchema.parse(beforeValidate);
|
|
189
|
+
const beforeSave = applyFieldHooks(parsed, "beforeSave");
|
|
190
|
+
const transformed = transformRelations(beforeSave, false);
|
|
191
|
+
return db.create(collection.name, transformed);
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async update(id: string, data: Record<string, unknown>) {
|
|
195
|
+
requireAccess("update");
|
|
196
|
+
const allowed = filterWritableFields(data, "update");
|
|
197
|
+
const beforeValidate = applyFieldHooks(allowed, "beforeValidate");
|
|
198
|
+
const parsed = validationSchema.partial().parse(beforeValidate);
|
|
199
|
+
const beforeSave = applyFieldHooks(parsed, "beforeSave");
|
|
200
|
+
const transformed = transformRelations(beforeSave, true);
|
|
201
|
+
return db.update(collection.name, id, transformed);
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
async delete(id: string) {
|
|
205
|
+
requireAccess("delete");
|
|
206
|
+
return db.delete(collection.name, id);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
function transformRelations(data: Record<string, unknown>, isUpdate: boolean) {
|
|
211
|
+
const transformed = { ...data };
|
|
212
|
+
for (const [key, value] of Object.entries(transformed)) {
|
|
213
|
+
const field = collection.fields[key];
|
|
214
|
+
if (field?.type === "relation") {
|
|
215
|
+
const relationType = field.db.relationType ?? "many-to-one";
|
|
216
|
+
const isMulti = relationType === "many-to-many" || relationType === "one-to-many";
|
|
217
|
+
|
|
218
|
+
if (Array.isArray(value)) {
|
|
219
|
+
const ids = value.filter((id): id is string => typeof id === "string" && id.length > 0);
|
|
220
|
+
transformed[key] = isUpdate
|
|
221
|
+
? { set: ids.map((id) => ({ id })) }
|
|
222
|
+
: { connect: ids.map((id) => ({ id })) };
|
|
223
|
+
} else if (!isMulti && typeof value === "string" && value.length > 0) {
|
|
224
|
+
transformed[key] = { connect: { id: value } };
|
|
225
|
+
} else if ((value === null || value === "") && isUpdate) {
|
|
226
|
+
transformed[key] = { disconnect: true };
|
|
227
|
+
} else if (value === null || value === "") {
|
|
228
|
+
delete transformed[key];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return transformed;
|
|
233
|
+
}
|
|
234
|
+
}
|
package/src/api/index.ts
ADDED
package/src/api/next.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import type { AdminForgeConfig, CollectionDefinition } from "../core";
|
|
2
|
+
import type { DbClient } from "../db";
|
|
3
|
+
import { createController } from "./controller.js";
|
|
4
|
+
import { verifyAgentToken, type SecurityContext } from "./security/agent-auth.js";
|
|
5
|
+
|
|
6
|
+
interface RouteContext {
|
|
7
|
+
params: Promise<Record<string, string>>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
11
|
+
return new Response(JSON.stringify(data), {
|
|
12
|
+
status,
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getBody(request: Request): Promise<Record<string, unknown>> {
|
|
18
|
+
return request.json().catch(() => ({}));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface RouteParams {
|
|
22
|
+
config: AdminForgeConfig;
|
|
23
|
+
db: DbClient;
|
|
24
|
+
auth?: any; // The NextAuth auth() function
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createRouteHandlers({ config, db, auth }: RouteParams) {
|
|
28
|
+
// We'll create controllers per request to inject the correct security context
|
|
29
|
+
const getSecurity = async (request: Request): Promise<SecurityContext> => {
|
|
30
|
+
const authHeader = request.headers.get("authorization");
|
|
31
|
+
|
|
32
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
33
|
+
const token = authHeader.split(" ")[1];
|
|
34
|
+
try {
|
|
35
|
+
const agent = verifyAgentToken(token);
|
|
36
|
+
return {
|
|
37
|
+
source: "agent",
|
|
38
|
+
agent,
|
|
39
|
+
user: { id: agent.sub, role: agent.role }
|
|
40
|
+
};
|
|
41
|
+
} catch (e: any) {
|
|
42
|
+
console.error(`[Auth] Agent Verification Failed: ${e.message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Try to get session via NextAuth if provided
|
|
47
|
+
if (auth) {
|
|
48
|
+
try {
|
|
49
|
+
const session = await auth();
|
|
50
|
+
if (session?.user) {
|
|
51
|
+
return {
|
|
52
|
+
source: "user",
|
|
53
|
+
user: {
|
|
54
|
+
id: session.user.id || session.user.email,
|
|
55
|
+
role: (session as any).role || "user"
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
} catch (e: any) {
|
|
60
|
+
console.error(`[Auth] Session Retrieval Failed: ${e.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { source: "user" };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function generateHandlers(collectionName: string) {
|
|
68
|
+
const collection = config.collections.find((c) => c.name === collectionName);
|
|
69
|
+
if (!collection) {
|
|
70
|
+
throw new Error(`Collection "${collectionName}" not found in config`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
GET: async (request: Request, context: RouteContext) => {
|
|
75
|
+
const security = await getSecurity(request);
|
|
76
|
+
const controller = createController(collection, db, security);
|
|
77
|
+
const params = await context.params;
|
|
78
|
+
if (params.id) {
|
|
79
|
+
const result = await controller.get(params.id);
|
|
80
|
+
if (!result) return jsonResponse({ error: "Not found" }, 404);
|
|
81
|
+
return jsonResponse(result);
|
|
82
|
+
}
|
|
83
|
+
const url = new URL(request.url);
|
|
84
|
+
const page = parseInt(url.searchParams.get("page") ?? "1");
|
|
85
|
+
const pageSize = parseInt(url.searchParams.get("pageSize") ?? "10");
|
|
86
|
+
const search = url.searchParams.get("search") ?? undefined;
|
|
87
|
+
const result = await controller.list({ page, pageSize, search });
|
|
88
|
+
return jsonResponse(result);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
POST: async (request: Request) => {
|
|
92
|
+
const security = await getSecurity(request);
|
|
93
|
+
const controller = createController(collection, db, security);
|
|
94
|
+
const body = await getBody(request);
|
|
95
|
+
try {
|
|
96
|
+
const result = await controller.create(body);
|
|
97
|
+
return jsonResponse(result, 201);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const error = err as any;
|
|
100
|
+
if (error.name === "ZodError") {
|
|
101
|
+
const issues = error.issues.map((i: any) => `${i.path.join(".")}: ${i.message}`).join(", ");
|
|
102
|
+
return jsonResponse({ error: `Validation failed: ${issues}` }, 400);
|
|
103
|
+
}
|
|
104
|
+
return jsonResponse({ error: error.message }, 400);
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
PATCH: async (request: Request, context: RouteContext) => {
|
|
109
|
+
const security = await getSecurity(request);
|
|
110
|
+
const controller = createController(collection, db, security);
|
|
111
|
+
const params = await context.params;
|
|
112
|
+
if (!params.id) return jsonResponse({ error: "ID required" }, 400);
|
|
113
|
+
const body = await getBody(request);
|
|
114
|
+
try {
|
|
115
|
+
const result = await controller.update(params.id, body);
|
|
116
|
+
return jsonResponse(result);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
const error = err as any;
|
|
119
|
+
if (error.name === "ZodError") {
|
|
120
|
+
const issues = error.issues.map((i: any) => `${i.path.join(".")}: ${i.message}`).join(", ");
|
|
121
|
+
return jsonResponse({ error: `Validation failed: ${issues}` }, 400);
|
|
122
|
+
}
|
|
123
|
+
return jsonResponse({ error: error.message }, 400);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
DELETE: async (request: Request, context: RouteContext) => {
|
|
128
|
+
const security = await getSecurity(request);
|
|
129
|
+
const controller = createController(collection, db, security);
|
|
130
|
+
const params = await context.params;
|
|
131
|
+
if (!params.id) return jsonResponse({ error: "ID required" }, 400);
|
|
132
|
+
try {
|
|
133
|
+
const result = await controller.delete(params.id);
|
|
134
|
+
return jsonResponse(result);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const error = err as Error;
|
|
137
|
+
return jsonResponse({ error: error.message }, 400);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { generateHandlers };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createAdminForgeApi({ config, db, auth }: RouteParams) {
|
|
147
|
+
const { generateHandlers } = createRouteHandlers({ config, db, auth });
|
|
148
|
+
|
|
149
|
+
const getCollectionAndId = (slug: string[]) => {
|
|
150
|
+
const [collectionName, id] = slug;
|
|
151
|
+
const handlers = generateHandlers(collectionName);
|
|
152
|
+
return { handlers, id };
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
async GET(request: Request, { params }: { params: Promise<any> }) {
|
|
157
|
+
try {
|
|
158
|
+
const resolvedParams = await params;
|
|
159
|
+
const slug = resolvedParams.slug || resolvedParams.admin || Object.values(resolvedParams)[0] as string[];
|
|
160
|
+
|
|
161
|
+
// Return serialized config for the UI
|
|
162
|
+
// Detect _config anywhere in the slug to support various mounting points
|
|
163
|
+
if (slug.includes("_config")) {
|
|
164
|
+
if (config.auth?.enabled && auth) {
|
|
165
|
+
try {
|
|
166
|
+
const session = await auth();
|
|
167
|
+
if (!session?.user) {
|
|
168
|
+
return jsonResponse({ error: "Unauthorized" }, 401);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
return jsonResponse({ error: "Unauthorized" }, 401);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const { serializeConfig } = await import("../core/index.js");
|
|
175
|
+
return jsonResponse(serializeConfig(config));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const { handlers, id } = getCollectionAndId(slug);
|
|
179
|
+
return handlers.GET(request, { params: Promise.resolve({ id: id || "" }) });
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return jsonResponse({ error: (err as Error).message }, 404);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async POST(request: Request, { params }: { params: Promise<any> }) {
|
|
186
|
+
try {
|
|
187
|
+
const resolvedParams = await params;
|
|
188
|
+
const slug = resolvedParams.slug || resolvedParams.admin || Object.values(resolvedParams)[0] as string[];
|
|
189
|
+
|
|
190
|
+
// Handle Media Uploads
|
|
191
|
+
if (slug[0] === "_media") {
|
|
192
|
+
const formData = await request.formData();
|
|
193
|
+
const file = formData.get("file") as File;
|
|
194
|
+
if (!file) return jsonResponse({ error: "No file uploaded" }, 400);
|
|
195
|
+
|
|
196
|
+
const bytes = await file.arrayBuffer();
|
|
197
|
+
const buffer = Buffer.from(bytes);
|
|
198
|
+
|
|
199
|
+
const path = await import("path");
|
|
200
|
+
const fs = await import("fs/promises");
|
|
201
|
+
const uploadDir = path.join(process.cwd(), "public", "uploads");
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
await fs.mkdir(uploadDir, { recursive: true });
|
|
205
|
+
} catch {}
|
|
206
|
+
|
|
207
|
+
const filename = `${Date.now()}-${file.name.replace(/\s+/g, "-")}`;
|
|
208
|
+
const filePath = path.join(uploadDir, filename);
|
|
209
|
+
await fs.writeFile(filePath, buffer);
|
|
210
|
+
|
|
211
|
+
return jsonResponse({
|
|
212
|
+
url: `/uploads/${filename}`,
|
|
213
|
+
filename
|
|
214
|
+
}, 201);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Handle Agent Token Generation
|
|
218
|
+
if (slug[0] === "_tokens") {
|
|
219
|
+
const { generateAgentToken } = await import("./security/agent-auth.js");
|
|
220
|
+
const body = await request.json();
|
|
221
|
+
const { scope, expiresIn = 600 } = body;
|
|
222
|
+
|
|
223
|
+
if (!Array.isArray(scope)) {
|
|
224
|
+
return jsonResponse({ error: "Scope must be an array" }, 400);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Validation: Ensure collections exist
|
|
228
|
+
for (const s of scope) {
|
|
229
|
+
const [collection, action] = s.split(":");
|
|
230
|
+
const exists = config.collections.find(c => c.name === collection);
|
|
231
|
+
if (!exists) return jsonResponse({ error: `Invalid collection: ${collection}` }, 400);
|
|
232
|
+
if (!["create", "read", "update", "delete"].includes(action)) {
|
|
233
|
+
return jsonResponse({ error: `Invalid action: ${action}` }, 400);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Auth: Get session
|
|
238
|
+
let userId = "admin";
|
|
239
|
+
let role = "admin";
|
|
240
|
+
if (auth) {
|
|
241
|
+
const session = await auth();
|
|
242
|
+
if (session?.user) {
|
|
243
|
+
userId = session.user.id || session.user.email;
|
|
244
|
+
role = (session as any).role || "admin";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const token = generateAgentToken(userId, role, scope, expiresIn);
|
|
249
|
+
return jsonResponse({ token });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const { handlers } = getCollectionAndId(slug);
|
|
253
|
+
return handlers.POST(request);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
return jsonResponse({ error: (err as Error).message }, 404);
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async PATCH(request: Request, { params }: { params: Promise<any> }) {
|
|
260
|
+
try {
|
|
261
|
+
const resolvedParams = await params;
|
|
262
|
+
const slug = resolvedParams.slug || resolvedParams.admin || Object.values(resolvedParams)[0] as string[];
|
|
263
|
+
const { handlers, id } = getCollectionAndId(slug);
|
|
264
|
+
return handlers.PATCH(request, { params: Promise.resolve({ id: id || "" }) });
|
|
265
|
+
} catch (err) {
|
|
266
|
+
return jsonResponse({ error: (err as Error).message }, 404);
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
async DELETE(request: Request, { params }: { params: Promise<any> }) {
|
|
271
|
+
try {
|
|
272
|
+
const resolvedParams = await params;
|
|
273
|
+
const slug = resolvedParams.slug || resolvedParams.admin || Object.values(resolvedParams)[0] as string[];
|
|
274
|
+
const { handlers, id } = getCollectionAndId(slug);
|
|
275
|
+
return handlers.DELETE(request, { params: Promise.resolve({ id: id || "" }) });
|
|
276
|
+
} catch (err) {
|
|
277
|
+
return jsonResponse({ error: (err as Error).message }, 404);
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|