frappebun 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/package.json +59 -0
- package/src/api/auth.ts +76 -0
- package/src/api/index.ts +10 -0
- package/src/api/resource.ts +177 -0
- package/src/api/route.ts +301 -0
- package/src/app/index.ts +6 -0
- package/src/app/loader.ts +218 -0
- package/src/auth/auth.ts +247 -0
- package/src/auth/index.ts +2 -0
- package/src/cli/args.ts +40 -0
- package/src/cli/bin.ts +12 -0
- package/src/cli/commands/add-api.ts +32 -0
- package/src/cli/commands/add-doctype.ts +43 -0
- package/src/cli/commands/add-page.ts +33 -0
- package/src/cli/commands/add-user.ts +96 -0
- package/src/cli/commands/dev.ts +71 -0
- package/src/cli/commands/drop-site.ts +27 -0
- package/src/cli/commands/init.ts +98 -0
- package/src/cli/commands/migrate.ts +110 -0
- package/src/cli/commands/new-site.ts +61 -0
- package/src/cli/commands/routes.ts +56 -0
- package/src/cli/commands/use.ts +30 -0
- package/src/cli/index.ts +73 -0
- package/src/cli/log.ts +13 -0
- package/src/cli/scaffold/templates.ts +189 -0
- package/src/context.ts +162 -0
- package/src/core/doctype/migration/migration.ts +17 -0
- package/src/core/doctype/role/role.ts +7 -0
- package/src/core/doctype/session/session.ts +16 -0
- package/src/core/doctype/user/user.controller.ts +11 -0
- package/src/core/doctype/user/user.ts +22 -0
- package/src/core/doctype/user_role/user_role.ts +9 -0
- package/src/core/doctypes.ts +25 -0
- package/src/core/index.ts +1 -0
- package/src/database/database.ts +359 -0
- package/src/database/filters.ts +131 -0
- package/src/database/index.ts +30 -0
- package/src/database/query-builder.ts +1118 -0
- package/src/database/schema.ts +188 -0
- package/src/doctype/define.ts +45 -0
- package/src/doctype/discovery.ts +57 -0
- package/src/doctype/field.ts +160 -0
- package/src/doctype/index.ts +20 -0
- package/src/doctype/layout.ts +62 -0
- package/src/doctype/query-builder-stub.ts +16 -0
- package/src/doctype/registry.ts +106 -0
- package/src/doctype/types.ts +407 -0
- package/src/document/document.ts +593 -0
- package/src/document/index.ts +6 -0
- package/src/document/naming.ts +56 -0
- package/src/errors.ts +53 -0
- package/src/frappe.d.ts +128 -0
- package/src/globals.ts +72 -0
- package/src/index.ts +112 -0
- package/src/migrations/index.ts +11 -0
- package/src/migrations/runner.ts +256 -0
- package/src/permissions/index.ts +265 -0
- package/src/response.ts +100 -0
- package/src/server.ts +210 -0
- package/src/site.ts +126 -0
- package/src/ssr/handler.ts +56 -0
- package/src/ssr/index.ts +11 -0
- package/src/ssr/page-loader.ts +200 -0
- package/src/ssr/renderer.ts +94 -0
- package/src/ssr/use-context.ts +41 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* frappe.perms — role-based access control with row-level scoping.
|
|
3
|
+
*
|
|
4
|
+
* Three layers applied in order:
|
|
5
|
+
* 1. Role check — does this user's role allow this action on this DocType?
|
|
6
|
+
* 2. Scope — for list queries: which rows can this user see?
|
|
7
|
+
* 3. Document — for individual docs: can this user perform this action?
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { FrappeDatabase } from "../database/database"
|
|
11
|
+
import { getDocTypeMeta, getController } from "../doctype/registry"
|
|
12
|
+
import type { DocumentLike, PermissionDenial, PermissionRule } from "../doctype/types"
|
|
13
|
+
import { PermissionError } from "../errors"
|
|
14
|
+
|
|
15
|
+
// ─── PermissionDenial sentinel ────────────────────────────
|
|
16
|
+
|
|
17
|
+
export function isDenial(v: unknown): v is PermissionDenial {
|
|
18
|
+
return (
|
|
19
|
+
typeof v === "object" && v !== null && (v as PermissionDenial).__type === "PermissionDenial"
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Signal a contextual denial with a human-readable message. */
|
|
24
|
+
export function deny(message: string): PermissionDenial {
|
|
25
|
+
return { __type: "PermissionDenial", message }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Role helpers (per-request cache) ────────────────────
|
|
29
|
+
|
|
30
|
+
const roleCache = new WeakMap<object, Map<string, Set<string>>>()
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get the roles for a user. Results are cached per-request
|
|
34
|
+
* (keyed by the `ctx` object reference — rotated each request).
|
|
35
|
+
*/
|
|
36
|
+
export function getUserRoles(user: string, db: FrappeDatabase, ctxRef: object): Set<string> {
|
|
37
|
+
let reqCache = roleCache.get(ctxRef)
|
|
38
|
+
if (!reqCache) {
|
|
39
|
+
reqCache = new Map()
|
|
40
|
+
roleCache.set(ctxRef, reqCache)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const cached = reqCache.get(user)
|
|
44
|
+
if (cached) return cached
|
|
45
|
+
|
|
46
|
+
const roles = new Set<string>(["All"])
|
|
47
|
+
|
|
48
|
+
if (user === "Administrator") {
|
|
49
|
+
roles.add("Administrator")
|
|
50
|
+
roles.add("System Manager")
|
|
51
|
+
} else {
|
|
52
|
+
try {
|
|
53
|
+
// UserRole table: columns: user, role (plus standard child fields)
|
|
54
|
+
const rows = db.raw
|
|
55
|
+
.query<{ role: string }, [string]>(`SELECT "role" FROM "UserRole" WHERE "user" = ?`)
|
|
56
|
+
.all(user)
|
|
57
|
+
for (const r of rows) roles.add(r.role)
|
|
58
|
+
} catch {
|
|
59
|
+
// UserRole table may not exist yet during migration
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
reqCache.set(user, roles)
|
|
64
|
+
return roles
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Role permission check ────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if the user has the given action on the DocType based on role permissions.
|
|
71
|
+
* Returns true for Administrator (always) and false if the DocType has no permissions array.
|
|
72
|
+
*/
|
|
73
|
+
export function hasRolePermission(
|
|
74
|
+
doctype: string,
|
|
75
|
+
action: string,
|
|
76
|
+
user: string,
|
|
77
|
+
db: FrappeDatabase,
|
|
78
|
+
ctxRef: object,
|
|
79
|
+
): boolean {
|
|
80
|
+
if (user === "Administrator") return true
|
|
81
|
+
|
|
82
|
+
const meta = getDocTypeMeta(doctype)
|
|
83
|
+
if (!meta) return false
|
|
84
|
+
|
|
85
|
+
const permRules: PermissionRule[] = meta.schema.permissions ?? []
|
|
86
|
+
if (permRules.length === 0) return true // no restrictions declared
|
|
87
|
+
|
|
88
|
+
const roles = getUserRoles(user, db, ctxRef)
|
|
89
|
+
|
|
90
|
+
return permRules.some((rule) => {
|
|
91
|
+
const roleMatch = rule.role === "All" || roles.has(rule.role)
|
|
92
|
+
if (!roleMatch) return false
|
|
93
|
+
return rule[action as keyof PermissionRule] === true
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Document-level check ────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Run the controller's per-action permission method.
|
|
101
|
+
* Returns true if allowed, throws PermissionError if denied.
|
|
102
|
+
*/
|
|
103
|
+
export async function checkDocPermission(
|
|
104
|
+
doctype: string,
|
|
105
|
+
action: "read" | "write" | "delete" | "submit" | "cancel",
|
|
106
|
+
doc: DocumentLike,
|
|
107
|
+
user: string,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
if (user === "Administrator") return
|
|
110
|
+
|
|
111
|
+
const controller = getController(doctype)
|
|
112
|
+
const permFn = controller?.permissions?.[action]
|
|
113
|
+
if (!permFn) return
|
|
114
|
+
|
|
115
|
+
const result = await (permFn as (doc: DocumentLike, user: string) => unknown)(doc, user)
|
|
116
|
+
evaluatePermResult(result, action, doctype)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Run the controller's create-permission check (no doc yet).
|
|
121
|
+
*/
|
|
122
|
+
export async function checkCreatePermission(doctype: string, user: string): Promise<void> {
|
|
123
|
+
if (user === "Administrator") return
|
|
124
|
+
|
|
125
|
+
const controller = getController(doctype)
|
|
126
|
+
const createFn = controller?.permissions?.create
|
|
127
|
+
if (!createFn) return
|
|
128
|
+
|
|
129
|
+
const result = await createFn(user)
|
|
130
|
+
evaluatePermResult(result, "create", doctype)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function evaluatePermResult(result: unknown, action: string, doctype: string): void {
|
|
134
|
+
if (result === true) return
|
|
135
|
+
if (isDenial(result)) throw new PermissionError(result.message)
|
|
136
|
+
if (result === false || result == null)
|
|
137
|
+
throw new PermissionError(`No permission to ${action} ${doctype}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── frappe.perms namespace ───────────────────────────────
|
|
141
|
+
|
|
142
|
+
export interface PermsNamespace {
|
|
143
|
+
/**
|
|
144
|
+
* Check if the current user has the given action on the DocType.
|
|
145
|
+
* Role check only (no doc loaded). Returns boolean, never throws.
|
|
146
|
+
*/
|
|
147
|
+
can(action: string, doctype: string): boolean
|
|
148
|
+
can(action: string, doctype: string, doc: DocumentLike): Promise<boolean>
|
|
149
|
+
can(action: string, doctype: string, name: string): Promise<boolean>
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Assert permission — throws PermissionError if denied.
|
|
153
|
+
*/
|
|
154
|
+
authorize(action: string, doctype: string): void
|
|
155
|
+
authorize(action: string, doctype: string, doc: DocumentLike): Promise<void>
|
|
156
|
+
authorize(action: string, doctype: string, name: string): Promise<void>
|
|
157
|
+
|
|
158
|
+
/** Check if the current user has the role (or any of the listed roles). */
|
|
159
|
+
hasRole(role: string | string[], user?: string): boolean
|
|
160
|
+
|
|
161
|
+
/** Get all roles for the current user (or a specified user). */
|
|
162
|
+
getRoles(user?: string): string[]
|
|
163
|
+
|
|
164
|
+
/** Produce a PermissionDenial with a descriptive message. */
|
|
165
|
+
deny(message: string): PermissionDenial
|
|
166
|
+
|
|
167
|
+
/** Share a document with another user. */
|
|
168
|
+
share(
|
|
169
|
+
doctype: string,
|
|
170
|
+
name: string,
|
|
171
|
+
targetUser: string,
|
|
172
|
+
perms: { read?: boolean; write?: boolean },
|
|
173
|
+
): Promise<void>
|
|
174
|
+
unshare(doctype: string, name: string, targetUser: string): Promise<void>
|
|
175
|
+
getShares(
|
|
176
|
+
doctype: string,
|
|
177
|
+
name: string,
|
|
178
|
+
): Promise<{ user: string; read: boolean; write: boolean }[]>
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function createPermsNamespace(
|
|
182
|
+
user: string,
|
|
183
|
+
db: FrappeDatabase,
|
|
184
|
+
ctxRef: object,
|
|
185
|
+
getDoc: (doctype: string, name: string) => Promise<DocumentLike>,
|
|
186
|
+
): PermsNamespace {
|
|
187
|
+
const self: PermsNamespace = {
|
|
188
|
+
can(
|
|
189
|
+
action: string,
|
|
190
|
+
doctype: string,
|
|
191
|
+
docOrName?: DocumentLike | string,
|
|
192
|
+
): boolean | Promise<boolean> {
|
|
193
|
+
const roleOk = hasRolePermission(doctype, action, user, db, ctxRef)
|
|
194
|
+
if (!roleOk) return false
|
|
195
|
+
if (docOrName === undefined) return true
|
|
196
|
+
|
|
197
|
+
// Doc-level check
|
|
198
|
+
return (async () => {
|
|
199
|
+
const doc: DocumentLike =
|
|
200
|
+
typeof docOrName === "string" ? await getDoc(doctype, docOrName) : docOrName
|
|
201
|
+
try {
|
|
202
|
+
await checkDocPermission(doctype, action as "read", doc, user)
|
|
203
|
+
return true
|
|
204
|
+
} catch {
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
})()
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
authorize(
|
|
211
|
+
action: string,
|
|
212
|
+
doctype: string,
|
|
213
|
+
docOrName?: DocumentLike | string,
|
|
214
|
+
): void | Promise<void> {
|
|
215
|
+
const roleOk = hasRolePermission(doctype, action, user, db, ctxRef)
|
|
216
|
+
if (!roleOk) throw new PermissionError(`No permission to ${action} ${doctype}`)
|
|
217
|
+
if (docOrName === undefined) return
|
|
218
|
+
|
|
219
|
+
return (async () => {
|
|
220
|
+
const doc: DocumentLike =
|
|
221
|
+
typeof docOrName === "string" ? await getDoc(doctype, docOrName) : docOrName
|
|
222
|
+
await checkDocPermission(doctype, action as "read", doc, user)
|
|
223
|
+
})()
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
hasRole(roleOrRoles: string | string[], targetUser?: string): boolean {
|
|
227
|
+
const u = targetUser ?? user
|
|
228
|
+
if (u === "Administrator") return true
|
|
229
|
+
const roles = getUserRoles(u, db, ctxRef)
|
|
230
|
+
return Array.isArray(roleOrRoles)
|
|
231
|
+
? roleOrRoles.some((r) => roles.has(r))
|
|
232
|
+
: roles.has(roleOrRoles)
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
getRoles(targetUser?: string): string[] {
|
|
236
|
+
return [...getUserRoles(targetUser ?? user, db, ctxRef)]
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
deny(message: string): PermissionDenial {
|
|
240
|
+
return deny(message)
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async share(doctype, name, targetUser, perms) {
|
|
244
|
+
// Placeholder: will use DocShare DocType when implemented
|
|
245
|
+
void doctype
|
|
246
|
+
void name
|
|
247
|
+
void targetUser
|
|
248
|
+
void perms
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
async unshare(doctype, name, targetUser) {
|
|
252
|
+
void doctype
|
|
253
|
+
void name
|
|
254
|
+
void targetUser
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
async getShares(doctype, name) {
|
|
258
|
+
void doctype
|
|
259
|
+
void name
|
|
260
|
+
return []
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return self
|
|
265
|
+
}
|
package/src/response.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* frappe.response — HTTP Response factory helpers.
|
|
3
|
+
*
|
|
4
|
+
* Available on every request context as `frappe.response.*` and importable
|
|
5
|
+
* directly for use in framework internals.
|
|
6
|
+
*
|
|
7
|
+
* Handlers returning a plain value are wrapped automatically by invokeRoute().
|
|
8
|
+
* Use these helpers when you need explicit control over status codes or headers.
|
|
9
|
+
*
|
|
10
|
+
* Examples:
|
|
11
|
+
* return frappe.response.ok({ name: "INV-001" })
|
|
12
|
+
* return frappe.response.created(doc.asDict())
|
|
13
|
+
* return frappe.response.error("Not enough stock", 409)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ─── Standard envelope shapes ─────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface DataEnvelope {
|
|
19
|
+
data: unknown
|
|
20
|
+
}
|
|
21
|
+
export interface ErrorEnvelope {
|
|
22
|
+
error: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Response helpers ─────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** `{ data }` with 200 OK. */
|
|
28
|
+
function ok(data: unknown): Response {
|
|
29
|
+
return build({ data }, 200)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Alias for `ok()` — matches the natural instinct to call
|
|
34
|
+
* `frappe.response.json(...)`. Returns `{ data }` with 200.
|
|
35
|
+
*/
|
|
36
|
+
const json = ok
|
|
37
|
+
|
|
38
|
+
/** `{ data }` with 201 Created. */
|
|
39
|
+
function created(data: unknown): Response {
|
|
40
|
+
return build({ data }, 201)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `{ data }` with a custom status code.
|
|
45
|
+
* Use for 2xx codes where `ok` and `created` don't fit (e.g. 202 Accepted).
|
|
46
|
+
*/
|
|
47
|
+
function success(data: unknown, status = 200): Response {
|
|
48
|
+
return build({ data }, status)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `{ error: message }` with an explicit HTTP status. */
|
|
52
|
+
function error(message: string, status = 400): Response {
|
|
53
|
+
return build({ error: message }, status)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 401 Unauthorized. */
|
|
57
|
+
function unauthorized(message = "Authentication required"): Response {
|
|
58
|
+
return error(message, 401)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 403 Forbidden. */
|
|
62
|
+
function forbidden(message = "Permission denied"): Response {
|
|
63
|
+
return error(message, 403)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 404 Not Found. */
|
|
67
|
+
function notFound(message = "Not found"): Response {
|
|
68
|
+
return error(message, 404)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 405 Method Not Allowed. */
|
|
72
|
+
function methodNotAllowed(message = "Method not allowed"): Response {
|
|
73
|
+
return error(message, 405)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Namespace export ─────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/** frappe.response — HTTP Response factory helpers. */
|
|
79
|
+
export const response = {
|
|
80
|
+
ok,
|
|
81
|
+
json,
|
|
82
|
+
created,
|
|
83
|
+
success,
|
|
84
|
+
error,
|
|
85
|
+
unauthorized,
|
|
86
|
+
forbidden,
|
|
87
|
+
notFound,
|
|
88
|
+
methodNotAllowed,
|
|
89
|
+
} as const
|
|
90
|
+
|
|
91
|
+
export type FrappeResponse = typeof response
|
|
92
|
+
|
|
93
|
+
// ─── Internal helper ──────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function build(body: object, status: number): Response {
|
|
96
|
+
return new Response(JSON.stringify(body), {
|
|
97
|
+
status,
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
})
|
|
100
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun HTTP server — site resolution, per-request context, and dispatch.
|
|
3
|
+
*
|
|
4
|
+
* Built-in endpoint modules live in src/api/ and follow the same route()
|
|
5
|
+
* convention as user app endpoints. They are registered as fixed (system)
|
|
6
|
+
* routes at startup so they survive routeRegistry.clear() in tests.
|
|
7
|
+
*
|
|
8
|
+
* Dispatch order:
|
|
9
|
+
* 1. api/method — built-in debug/test helpers
|
|
10
|
+
* 2. routeRegistry — framework routes first (fixed), then user routes (dynamic)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Server } from "bun"
|
|
14
|
+
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
15
|
+
import { join } from "node:path"
|
|
16
|
+
type AnyServer = Server<undefined>
|
|
17
|
+
import { isRouteDefinition, invokeRoute, routeRegistry } from "./api/route"
|
|
18
|
+
import { resolveAuth, verifyCsrf } from "./auth/auth"
|
|
19
|
+
import { contextStore, createContext } from "./context"
|
|
20
|
+
import { registerCoreDocTypes } from "./core/doctypes"
|
|
21
|
+
import { FrappeDatabase } from "./database/database"
|
|
22
|
+
import { FrappeError, ValidationError } from "./errors"
|
|
23
|
+
import { response } from "./response"
|
|
24
|
+
import { resolveSite, loadSiteConfig } from "./site"
|
|
25
|
+
import { handlePageRequest, type PageMatcher } from "./ssr/handler"
|
|
26
|
+
|
|
27
|
+
export interface ServerOptions {
|
|
28
|
+
port?: number
|
|
29
|
+
sitesDir: string
|
|
30
|
+
/** Static-route page matcher from the SSR subsystem. If provided, GET requests
|
|
31
|
+
* are tried against it before falling through to route/404 handling. */
|
|
32
|
+
pageMatcher?: PageMatcher
|
|
33
|
+
/** HTML document title for SSR'd pages. */
|
|
34
|
+
pageTitle?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Site DB cache ─────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const siteDbCache = new Map<string, FrappeDatabase>()
|
|
40
|
+
|
|
41
|
+
function getOrCreateDb(site: string, sitesDir: string): FrappeDatabase {
|
|
42
|
+
const cached = siteDbCache.get(site)
|
|
43
|
+
if (cached) return cached
|
|
44
|
+
|
|
45
|
+
const siteConfig = loadSiteConfig(site, sitesDir)
|
|
46
|
+
const dbPath = join(sitesDir, site, "site.db")
|
|
47
|
+
const db = new FrappeDatabase(dbPath, { debug: Boolean(siteConfig["developer_mode"]) })
|
|
48
|
+
siteDbCache.set(site, db)
|
|
49
|
+
return db
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Initialize a site's database: register core DocTypes and migrate schema.
|
|
54
|
+
* Safe to call multiple times — re-uses cached DB after first call.
|
|
55
|
+
*/
|
|
56
|
+
export async function initSiteDb(site: string, sitesDir: string): Promise<FrappeDatabase> {
|
|
57
|
+
await registerCoreDocTypes()
|
|
58
|
+
const db = getOrCreateDb(site, sitesDir)
|
|
59
|
+
db.migrate()
|
|
60
|
+
return db
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Close and remove all cached site databases. Useful for test cleanup. */
|
|
64
|
+
export function closeAllDbs(): void {
|
|
65
|
+
for (const db of siteDbCache.values()) db.close()
|
|
66
|
+
siteDbCache.clear()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── Framework route registration ─────────────────────────
|
|
70
|
+
|
|
71
|
+
let frameworkRoutesRegistered = false
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Register all framework routes (auth, resource) as system routes.
|
|
75
|
+
* System routes survive routeRegistry.clear() — they're registered once
|
|
76
|
+
* at server startup and never need to be re-registered.
|
|
77
|
+
*/
|
|
78
|
+
async function registerFrameworkRoutes(): Promise<void> {
|
|
79
|
+
if (frameworkRoutesRegistered) return
|
|
80
|
+
frameworkRoutesRegistered = true
|
|
81
|
+
|
|
82
|
+
const [authModule, resourceModule] = await Promise.all([
|
|
83
|
+
import("./api/auth") as Promise<Record<string, unknown>>,
|
|
84
|
+
import("./api/resource") as Promise<Record<string, unknown>>,
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
for (const [name, def] of Object.entries({ ...authModule, ...resourceModule })) {
|
|
88
|
+
if (isRouteDefinition(def) && def.config.path) {
|
|
89
|
+
// path: on framework routes is the full absolute server path
|
|
90
|
+
routeRegistry.registerSystem(def.config.path, def, name)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Built-in debug/test methods ──────────────────────────
|
|
96
|
+
|
|
97
|
+
type BuiltinHandler = (req: Request) => unknown
|
|
98
|
+
|
|
99
|
+
const builtinMethods: Record<string, BuiltinHandler> = {
|
|
100
|
+
ping: () => "pong",
|
|
101
|
+
|
|
102
|
+
__test_error: () => {
|
|
103
|
+
throw new ValidationError("test error")
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
__test_context: () => {
|
|
107
|
+
const ctx = contextStore.getStore()
|
|
108
|
+
if (!ctx) throw new Error("No context")
|
|
109
|
+
return { user: ctx.user, site: ctx.site, env: ctx.env }
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Server ────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
export async function createServer(opts: ServerOptions): Promise<AnyServer> {
|
|
116
|
+
const { sitesDir, pageMatcher, pageTitle } = opts
|
|
117
|
+
|
|
118
|
+
await registerFrameworkRoutes()
|
|
119
|
+
|
|
120
|
+
if (existsSync(sitesDir)) {
|
|
121
|
+
for (const entry of readdirSync(sitesDir)) {
|
|
122
|
+
if (entry.startsWith(".") || entry === "common_site_config.json") continue
|
|
123
|
+
const entryPath = join(sitesDir, entry)
|
|
124
|
+
if (statSync(entryPath).isDirectory() && existsSync(join(entryPath, "site_config.json"))) {
|
|
125
|
+
await initSiteDb(entry, sitesDir)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return Bun.serve({
|
|
131
|
+
port: opts.port ?? 8000,
|
|
132
|
+
|
|
133
|
+
async fetch(req) {
|
|
134
|
+
const url = new URL(req.url)
|
|
135
|
+
|
|
136
|
+
let site: string
|
|
137
|
+
try {
|
|
138
|
+
site = resolveSite(req, sitesDir)
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return jsonResponse({ error: (e as Error).message }, 400)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const db = getOrCreateDb(site, sitesDir)
|
|
144
|
+
const auth = await resolveAuth(req, db)
|
|
145
|
+
|
|
146
|
+
const ctx = createContext({
|
|
147
|
+
user: auth.user,
|
|
148
|
+
site,
|
|
149
|
+
db,
|
|
150
|
+
sid: auth.sid,
|
|
151
|
+
csrfToken: auth.csrfToken,
|
|
152
|
+
request: req,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return contextStore.run(ctx, async () => {
|
|
156
|
+
try {
|
|
157
|
+
if (auth.sid && !verifyCsrf(req, auth.csrfToken)) {
|
|
158
|
+
return response.forbidden("CSRF token mismatch")
|
|
159
|
+
}
|
|
160
|
+
if (pageMatcher) {
|
|
161
|
+
const page = await handlePageRequest(req, url, pageMatcher, { title: pageTitle })
|
|
162
|
+
if (page) return page
|
|
163
|
+
}
|
|
164
|
+
return await dispatch(req, url)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return handleError(error)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Dispatcher ────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
async function dispatch(req: Request, url: URL): Promise<Response> {
|
|
176
|
+
const { pathname } = url
|
|
177
|
+
|
|
178
|
+
// api/method — built-in debug/test endpoints
|
|
179
|
+
const methodMatch = pathname.match(/^\/api\/method\/(.+)$/)
|
|
180
|
+
if (methodMatch) {
|
|
181
|
+
const name = methodMatch[1]!
|
|
182
|
+
const handler = builtinMethods[name]
|
|
183
|
+
if (!handler) return response.notFound(`Method "${name}" not found`)
|
|
184
|
+
// Built-in methods use { message } envelope (legacy shape)
|
|
185
|
+
const result = await handler(req)
|
|
186
|
+
return new Response(JSON.stringify({ message: result }), {
|
|
187
|
+
status: 200,
|
|
188
|
+
headers: { "Content-Type": "application/json" },
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// All other routes — framework (fixed) and user (dynamic) via registry
|
|
193
|
+
const matched = routeRegistry.match(pathname, req.method)
|
|
194
|
+
if (matched) return invokeRoute(matched.def, matched.params)
|
|
195
|
+
|
|
196
|
+
return response.notFound()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Utilities ─────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
function handleError(error: unknown): Response {
|
|
202
|
+
if (error instanceof FrappeError) {
|
|
203
|
+
return new Response(
|
|
204
|
+
JSON.stringify({ exc_type: error.name, message: error.message, title: error.title }),
|
|
205
|
+
{ status: error.httpStatus, headers: { "Content-Type": "application/json" } },
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
console.error("Unhandled error:", error)
|
|
209
|
+
return response.error("Internal Server Error", 500)
|
|
210
|
+
}
|
package/src/site.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site resolution and config loading for multi-tenancy.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
|
|
8
|
+
export interface SiteConfig {
|
|
9
|
+
[key: string]: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve which site a request is for.
|
|
14
|
+
*
|
|
15
|
+
* Resolution order:
|
|
16
|
+
* 1. X-Frappe-Site header
|
|
17
|
+
* 2. Host header (without port)
|
|
18
|
+
* 3. default_site from common_site_config.json
|
|
19
|
+
* 4. The only site (if exactly one exists)
|
|
20
|
+
*/
|
|
21
|
+
export function resolveSite(req: Request, sitesDir: string): string {
|
|
22
|
+
// 1. X-Frappe-Site header takes precedence
|
|
23
|
+
const xSite = req.headers.get("x-frappe-site")
|
|
24
|
+
if (xSite) {
|
|
25
|
+
validateSiteExists(xSite, sitesDir)
|
|
26
|
+
return xSite
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 2. Host header
|
|
30
|
+
const host = req.headers.get("host")
|
|
31
|
+
if (host) {
|
|
32
|
+
const hostname = host.split(":")[0]!
|
|
33
|
+
if (siteExists(hostname, sitesDir)) {
|
|
34
|
+
return hostname
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 3. default_site from common config
|
|
39
|
+
const commonConfig = loadCommonConfig(sitesDir)
|
|
40
|
+
if (commonConfig.default_site) {
|
|
41
|
+
validateSiteExists(commonConfig.default_site, sitesDir)
|
|
42
|
+
return commonConfig.default_site
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 4. Only site
|
|
46
|
+
const sites = listSites(sitesDir)
|
|
47
|
+
if (sites.length === 1) {
|
|
48
|
+
return sites[0]!
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (sites.length === 0) {
|
|
52
|
+
throw new Error("No sites found in " + sitesDir)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Could not resolve site. Multiple sites exist (${sites.join(", ")}). ` +
|
|
57
|
+
`Set default_site in common_site_config.json or pass X-Frappe-Site header.`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load merged site config.
|
|
63
|
+
* Priority: env vars > site_config.json > common_site_config.json
|
|
64
|
+
*/
|
|
65
|
+
export function loadSiteConfig(site: string, sitesDir: string): SiteConfig {
|
|
66
|
+
const common = loadCommonConfig(sitesDir)
|
|
67
|
+
const siteConfigPath = join(sitesDir, site, "site_config.json")
|
|
68
|
+
const siteConf = existsSync(siteConfigPath)
|
|
69
|
+
? JSON.parse(readFileSync(siteConfigPath, "utf-8"))
|
|
70
|
+
: {}
|
|
71
|
+
|
|
72
|
+
// Merge: common < site < env
|
|
73
|
+
const merged: SiteConfig = { ...common, ...siteConf }
|
|
74
|
+
|
|
75
|
+
// Apply FRAPPE_* environment variables
|
|
76
|
+
const envMapping: Record<string, string> = {
|
|
77
|
+
FRAPPE_DB_HOST: "db_host",
|
|
78
|
+
FRAPPE_DB_PORT: "db_port",
|
|
79
|
+
FRAPPE_DB_NAME: "db_name",
|
|
80
|
+
FRAPPE_DB_USER: "db_user",
|
|
81
|
+
FRAPPE_DB_PASSWORD: "db_password",
|
|
82
|
+
FRAPPE_DB_TYPE: "db_type",
|
|
83
|
+
FRAPPE_REDIS_URL: "redis_url",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const [envKey, configKey] of Object.entries(envMapping)) {
|
|
87
|
+
if (process.env[envKey]) {
|
|
88
|
+
merged[configKey] = process.env[envKey]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return merged
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Read the `default_site` from `common_site_config.json`, or undefined. */
|
|
96
|
+
export function getDefaultSite(sitesDir: string): string | undefined {
|
|
97
|
+
const config = loadCommonConfig(sitesDir)
|
|
98
|
+
return config.default_site as string | undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function loadCommonConfig(sitesDir: string): SiteConfig {
|
|
102
|
+
const commonPath = join(sitesDir, "common_site_config.json")
|
|
103
|
+
if (existsSync(commonPath)) {
|
|
104
|
+
return JSON.parse(readFileSync(commonPath, "utf-8"))
|
|
105
|
+
}
|
|
106
|
+
return {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function listSites(sitesDir: string): string[] {
|
|
110
|
+
if (!existsSync(sitesDir)) return []
|
|
111
|
+
return readdirSync(sitesDir).filter((entry) => {
|
|
112
|
+
if (entry.startsWith(".") || entry === "common_site_config.json") return false
|
|
113
|
+
const entryPath = join(sitesDir, entry)
|
|
114
|
+
return statSync(entryPath).isDirectory() && existsSync(join(entryPath, "site_config.json"))
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function siteExists(site: string, sitesDir: string): boolean {
|
|
119
|
+
return existsSync(join(sitesDir, site, "site_config.json"))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validateSiteExists(site: string, sitesDir: string): void {
|
|
123
|
+
if (!siteExists(site, sitesDir)) {
|
|
124
|
+
throw new Error(`Site "${site}" does not exist in ${sitesDir}`)
|
|
125
|
+
}
|
|
126
|
+
}
|