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.
Files changed (66) hide show
  1. package/README.md +72 -0
  2. package/package.json +59 -0
  3. package/src/api/auth.ts +76 -0
  4. package/src/api/index.ts +10 -0
  5. package/src/api/resource.ts +177 -0
  6. package/src/api/route.ts +301 -0
  7. package/src/app/index.ts +6 -0
  8. package/src/app/loader.ts +218 -0
  9. package/src/auth/auth.ts +247 -0
  10. package/src/auth/index.ts +2 -0
  11. package/src/cli/args.ts +40 -0
  12. package/src/cli/bin.ts +12 -0
  13. package/src/cli/commands/add-api.ts +32 -0
  14. package/src/cli/commands/add-doctype.ts +43 -0
  15. package/src/cli/commands/add-page.ts +33 -0
  16. package/src/cli/commands/add-user.ts +96 -0
  17. package/src/cli/commands/dev.ts +71 -0
  18. package/src/cli/commands/drop-site.ts +27 -0
  19. package/src/cli/commands/init.ts +98 -0
  20. package/src/cli/commands/migrate.ts +110 -0
  21. package/src/cli/commands/new-site.ts +61 -0
  22. package/src/cli/commands/routes.ts +56 -0
  23. package/src/cli/commands/use.ts +30 -0
  24. package/src/cli/index.ts +73 -0
  25. package/src/cli/log.ts +13 -0
  26. package/src/cli/scaffold/templates.ts +189 -0
  27. package/src/context.ts +162 -0
  28. package/src/core/doctype/migration/migration.ts +17 -0
  29. package/src/core/doctype/role/role.ts +7 -0
  30. package/src/core/doctype/session/session.ts +16 -0
  31. package/src/core/doctype/user/user.controller.ts +11 -0
  32. package/src/core/doctype/user/user.ts +22 -0
  33. package/src/core/doctype/user_role/user_role.ts +9 -0
  34. package/src/core/doctypes.ts +25 -0
  35. package/src/core/index.ts +1 -0
  36. package/src/database/database.ts +359 -0
  37. package/src/database/filters.ts +131 -0
  38. package/src/database/index.ts +30 -0
  39. package/src/database/query-builder.ts +1118 -0
  40. package/src/database/schema.ts +188 -0
  41. package/src/doctype/define.ts +45 -0
  42. package/src/doctype/discovery.ts +57 -0
  43. package/src/doctype/field.ts +160 -0
  44. package/src/doctype/index.ts +20 -0
  45. package/src/doctype/layout.ts +62 -0
  46. package/src/doctype/query-builder-stub.ts +16 -0
  47. package/src/doctype/registry.ts +106 -0
  48. package/src/doctype/types.ts +407 -0
  49. package/src/document/document.ts +593 -0
  50. package/src/document/index.ts +6 -0
  51. package/src/document/naming.ts +56 -0
  52. package/src/errors.ts +53 -0
  53. package/src/frappe.d.ts +128 -0
  54. package/src/globals.ts +72 -0
  55. package/src/index.ts +112 -0
  56. package/src/migrations/index.ts +11 -0
  57. package/src/migrations/runner.ts +256 -0
  58. package/src/permissions/index.ts +265 -0
  59. package/src/response.ts +100 -0
  60. package/src/server.ts +210 -0
  61. package/src/site.ts +126 -0
  62. package/src/ssr/handler.ts +56 -0
  63. package/src/ssr/index.ts +11 -0
  64. package/src/ssr/page-loader.ts +200 -0
  65. package/src/ssr/renderer.ts +94 -0
  66. 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
+ }
@@ -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
+ }