@nano-app/backend 0.16.0 → 0.17.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 +1 -1
- package/build/index.d.ts +7 -17
- package/build/index.js +151 -152
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ export default createRoute({
|
|
|
48
48
|
|
|
49
49
|
## Routes
|
|
50
50
|
|
|
51
|
-
Each `*.route.ts` under `mainPlugin.routesDir` default-exports an object with the HTTP methods it implements (`GET`, `POST`, `PUT`, `DELETE`, `QUERY`). **The file path is the URL** (`index.route.ts` → `/`, `api/users.route.ts` → `/api/users`). `expression` is appended to that path as is, in Express syntax (`/:id`, `{/:id}` for optional):
|
|
51
|
+
Each `*.route.ts` under `mainPlugin.routesDir` default-exports an object with the HTTP methods it implements (`GET`, `POST`, `PUT`, `DELETE`, `QUERY`). **The file path is the URL** (`index.route.ts` → `/`, `api/users.route.ts` → `/api/users`). A route file holds only those methods and its options (`expression`, `public`, `permissions`, `callbacks`): any other key, or an invalid value, stops the startup with the file and what is wrong. `expression` is appended to that path as is, in Express syntax (`/:id`, `{/:id}` for optional):
|
|
52
52
|
|
|
53
53
|
```ts
|
|
54
54
|
export default createRoute({
|
package/build/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import express, { Express, Request, Request as Request$1 } from "express";
|
|
|
2
2
|
import { CorsOptions } from "cors";
|
|
3
3
|
import "dotenv/config";
|
|
4
4
|
import "jiti";
|
|
5
|
+
import { z } from "zod";
|
|
5
6
|
import "better-auth";
|
|
6
7
|
/** API response */
|
|
7
8
|
type ApiResponse<T = any> = {
|
|
@@ -55,7 +56,7 @@ type Callback<Deps> = (params: Params<Deps>) => Promise<void>;
|
|
|
55
56
|
type CallbackMethod<Deps> = (req: Request$1, method: string, params: Params<Deps>) => Promise<NanoObject>;
|
|
56
57
|
/** Callback run with the Express app */
|
|
57
58
|
type CallbackMount<Deps> = (app: Express, params: Params<Deps>) => Promise<void>;
|
|
58
|
-
/** Callbacks of an endpoint
|
|
59
|
+
/** Callbacks of an endpoint; what affects the whole app goes in the plugin's onMount */
|
|
59
60
|
type Callbacks<Deps = NanoObject<any>> = {
|
|
60
61
|
/** Run once, when the endpoint is mounted */
|
|
61
62
|
onInit?: Callback<Deps>;
|
|
@@ -79,7 +80,7 @@ type RouteEntry<Deps = NanoObject<any>> = {
|
|
|
79
80
|
} & Handlers<Deps>;
|
|
80
81
|
/** Generic object */
|
|
81
82
|
type NanoObject<T = any> = Record<string, T>;
|
|
82
|
-
/** Error of the framework
|
|
83
|
+
/** Error of the framework, with the status a handler answers */
|
|
83
84
|
declare class NanoError extends Error {
|
|
84
85
|
status?: number;
|
|
85
86
|
/** Error with its message, and the status a handler answers (500 without one) */
|
|
@@ -89,7 +90,7 @@ declare class NanoError extends Error {
|
|
|
89
90
|
}
|
|
90
91
|
/** Database of a plugin or of the app */
|
|
91
92
|
type DatabaseConfig = {
|
|
92
|
-
/** Its Drizzle schema: the module of its tables
|
|
93
|
+
/** Its Drizzle schema: the module of its tables and `relations` */
|
|
93
94
|
schema: NanoObject;
|
|
94
95
|
};
|
|
95
96
|
/** When a plugin is mounted, relative to the consumer routes */
|
|
@@ -111,19 +112,8 @@ type PluginConfig<Options = NanoObject> = {
|
|
|
111
112
|
/** Plugin callbacks */
|
|
112
113
|
callbacks?: PluginCallbacks;
|
|
113
114
|
};
|
|
114
|
-
/** The project's plugin
|
|
115
|
-
type MainPluginConfig<Options = NanoObject> =
|
|
116
|
-
/** Directory of the project's `*.route.ts` files. Defaults to "src/routes" */
|
|
117
|
-
routesDir?: string;
|
|
118
|
-
/** Base path prepended to every route, e.g. "/api" (no trailing slash). Defaults to none */
|
|
119
|
-
basePath?: string;
|
|
120
|
-
/** Drizzle schema of the project's database; without it the app has none */
|
|
121
|
-
database?: DatabaseConfig;
|
|
122
|
-
/** Plugin options */
|
|
123
|
-
options?: Options;
|
|
124
|
-
/** Plugin callbacks */
|
|
125
|
-
callbacks?: PluginCallbacks;
|
|
126
|
-
};
|
|
115
|
+
/** The project's plugin, named "app": `routesDir` defaults to "src/routes" */
|
|
116
|
+
type MainPluginConfig<Options = NanoObject> = Partial<Omit<PluginConfig<Options>, 'name' | 'stage'>>;
|
|
127
117
|
/** Configuration of the framework's built-in packages */
|
|
128
118
|
type PackagesConfig = {
|
|
129
119
|
/** Morgan configuration */
|
|
@@ -160,7 +150,7 @@ type NanoAppConfig = {
|
|
|
160
150
|
mainPlugin?: MainPluginConfig;
|
|
161
151
|
};
|
|
162
152
|
/** Merges the consumer configuration over the initial one; the main plugin is always named "app" */
|
|
163
|
-
declare function mergeConfig(oConfig?: NanoAppConfig): NanoAppConfig & Required<Pick<NanoAppConfig, 'settings' | 'plugins' | 'packages'>> & {
|
|
153
|
+
declare function mergeConfig(oConfig?: NanoAppConfig): NanoAppConfig & Required<Pick<NanoAppConfig, 'port' | 'settings' | 'plugins' | 'packages'>> & {
|
|
164
154
|
mainPlugin: PluginConfig;
|
|
165
155
|
};
|
|
166
156
|
/** Main NanoApp class */
|
package/build/index.js
CHANGED
|
@@ -8,8 +8,9 @@ import { glob } from "glob";
|
|
|
8
8
|
import { isAbsolute, resolve } from "node:path";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { createJiti } from "jiti";
|
|
11
|
+
import { z } from "zod";
|
|
11
12
|
// ../core/utils/NanoError.ts
|
|
12
|
-
/** Error of the framework
|
|
13
|
+
/** Error of the framework, with the status a handler answers */
|
|
13
14
|
var NanoError = class extends Error {
|
|
14
15
|
status;
|
|
15
16
|
/** Error with its message, and the status a handler answers (500 without one) */
|
|
@@ -41,7 +42,7 @@ var FileReader = class {
|
|
|
41
42
|
// ../core/files/FileLoader.ts
|
|
42
43
|
/** Finds and loads the consumer's files */
|
|
43
44
|
var FileLoader = class {
|
|
44
|
-
/** Finds `*.<extension>.ts`
|
|
45
|
+
/** Finds the `*.<extension>.ts` files of a folder: `{ [subPath]: defaultExport }` */
|
|
45
46
|
static async findMany(sExtension, sSourceDir) {
|
|
46
47
|
const sSrcDir = (sSourceDir ?? `src/${sExtension}s`).replace(/^\.\//, "").replace(/\/$/, "");
|
|
47
48
|
const aFilePaths = await glob(`${sSrcDir}/**/*.${sExtension}.{ts,js}`, {
|
|
@@ -57,89 +58,20 @@ var FileLoader = class {
|
|
|
57
58
|
return oEntries;
|
|
58
59
|
}
|
|
59
60
|
};
|
|
60
|
-
// ../core/const.ts
|
|
61
|
-
/** HTTP methods a route can handle */
|
|
62
|
-
const HTTP_METHODS = [
|
|
63
|
-
"GET",
|
|
64
|
-
"POST",
|
|
65
|
-
"PUT",
|
|
66
|
-
"DELETE",
|
|
67
|
-
"QUERY"
|
|
68
|
-
];
|
|
69
|
-
/** Loopback and private networks: the proxies the backend trusts by default */
|
|
70
|
-
const PRIVATE_NETWORKS = [
|
|
71
|
-
"127.0.0.0/8",
|
|
72
|
-
"::1/128",
|
|
73
|
-
"10.0.0.0/8",
|
|
74
|
-
"172.16.0.0/12",
|
|
75
|
-
"192.168.0.0/16",
|
|
76
|
-
"fc00::/7"
|
|
77
|
-
];
|
|
78
|
-
// ../core/utils/Paths.ts
|
|
79
|
-
/** Paths of the app's pages */
|
|
80
|
-
var Paths = class {
|
|
81
|
-
/** Whether a path matches a pattern: exact (`/admin`), or the path and all below it (`/admin/*`) */
|
|
82
|
-
static matches(sPattern, sPath) {
|
|
83
|
-
if (!sPattern.endsWith("/*")) return sPattern === sPath;
|
|
84
|
-
const sBase = sPattern.slice(0, -2);
|
|
85
|
-
return sPath === sBase || sPath.startsWith(`${sBase}/`);
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
61
|
// ../core/utils/Validator.ts
|
|
89
|
-
const STORE_TYPES = ["app", "api"];
|
|
90
62
|
/** Checks values of the framework and the consumer's files; an invalid file throws a `NanoError` */
|
|
91
|
-
var Validator = class
|
|
63
|
+
var Validator = class {
|
|
92
64
|
/** Whether a value is a plain object */
|
|
93
65
|
static isObject(oValue) {
|
|
94
66
|
return Object.prototype.toString.call(oValue) === "[object Object]";
|
|
95
67
|
}
|
|
96
|
-
/**
|
|
97
|
-
static
|
|
98
|
-
return
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
/** First environment variable of a list that is not defined */
|
|
105
|
-
static missingEnv(aNames) {
|
|
106
|
-
return aNames.find((sName) => !process.env[sName]);
|
|
107
|
-
}
|
|
108
|
-
/** Checks every entry with a guard (`Validator.isRoute`...): the first invalid one throws */
|
|
109
|
-
static validate(oEntries, fnGuard) {
|
|
110
|
-
return Object.fromEntries(Object.entries(oEntries).filter(([sKey, oEntry]) => fnGuard(oEntry, sKey)));
|
|
111
|
-
}
|
|
112
|
-
/** Type guard for a `*.route.ts` entry */
|
|
113
|
-
static isRoute(oEntry, sFilePath) {
|
|
114
|
-
if (!Validator.isObject(oEntry)) throw new NanoError(`${sFilePath}: the default export is not an object.`);
|
|
115
|
-
if (oEntry.expression && typeof oEntry.expression !== "string") throw new NanoError(`${sFilePath}: "expression" must be a string.`);
|
|
116
|
-
if (oEntry.public !== void 0 && typeof oEntry.public !== "boolean") throw new NanoError(`${sFilePath}: "public" must be a boolean.`);
|
|
117
|
-
for (const sMethod of HTTP_METHODS) {
|
|
118
|
-
const oMethod = oEntry[sMethod];
|
|
119
|
-
if (oMethod && typeof oMethod !== "function" && typeof oMethod.handler !== "function") throw new NanoError(`${sFilePath}: "${sMethod}" must be a function or { handler, permissions }.`);
|
|
120
|
-
}
|
|
121
|
-
if (!HTTP_METHODS.some((sMethod) => oEntry[sMethod])) throw new NanoError(`${sFilePath}: defines no HTTP method.`);
|
|
122
|
-
return true;
|
|
123
|
-
}
|
|
124
|
-
/** Type guard for a `*.store.ts` entry */
|
|
125
|
-
static isStore(oEntry, sFilePath) {
|
|
126
|
-
if (!Validator.isObject(oEntry)) throw new NanoError(`${sFilePath}: the default export is not an object.`);
|
|
127
|
-
if (!STORE_TYPES.includes(oEntry.type)) throw new NanoError(`${sFilePath}: its type must be "app" or "api".`);
|
|
128
|
-
if (oEntry.data !== void 0 && !Validator.isObject(oEntry.data)) throw new NanoError(`${sFilePath}: "data" must be an object.`);
|
|
129
|
-
if (oEntry.headers !== void 0 && !Validator.isStringMap(oEntry.headers)) throw new NanoError(`${sFilePath}: "headers" must be an object of strings.`);
|
|
130
|
-
if (oEntry.auth !== void 0 && !Validator.isAuth(oEntry.auth)) throw new NanoError(`${sFilePath}: "auth" must be { path, routes }: each route { match, redirect, roles, permissions } with paths starting with /, roles as "admin,user", permissions as { resource: "read,update" }, and a redirect outside its match.`);
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
/** Whether `auth` has the base path of Better Auth and valid routes, none redirecting to itself */
|
|
134
|
-
static isAuth(oAuth) {
|
|
135
|
-
if (!Validator.isObject(oAuth) || typeof oAuth.path !== "string") return false;
|
|
136
|
-
const aRoutes = oAuth.routes ?? [];
|
|
137
|
-
if (!Array.isArray(aRoutes)) return false;
|
|
138
|
-
return aRoutes.every((oRoute) => {
|
|
139
|
-
if (!Validator.isObject(oRoute)) return false;
|
|
140
|
-
const { match, redirect = "/", roles, permissions } = oRoute;
|
|
141
|
-
return Validator.isPath(match) && Validator.isPath(redirect) && !Paths.matches(match, redirect) && (roles === void 0 || typeof roles === "string") && (permissions === void 0 || Validator.isStringMap(permissions));
|
|
142
|
-
});
|
|
68
|
+
/** Checks every entry with a zod schema (`ROUTE`, `STORE`): the first invalid one throws with its key */
|
|
69
|
+
static validate(oEntries, oSchema) {
|
|
70
|
+
return Object.fromEntries(Object.entries(oEntries).map(([sKey, oEntry]) => {
|
|
71
|
+
const oResult = oSchema.safeParse(oEntry);
|
|
72
|
+
if (!oResult.success) throw new NanoError(`${sKey}:\n${z.prettifyError(oResult.error)}`);
|
|
73
|
+
return [sKey, oResult.data];
|
|
74
|
+
}));
|
|
143
75
|
}
|
|
144
76
|
};
|
|
145
77
|
// ../core/utils/Logger.ts
|
|
@@ -166,58 +98,69 @@ var Logger = class Logger {
|
|
|
166
98
|
return oDate.toISOString().slice(0, 19).replace("T", " ");
|
|
167
99
|
}
|
|
168
100
|
};
|
|
169
|
-
//
|
|
170
|
-
/**
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const sFilePath = AUTH_FILES.find(existsSync);
|
|
178
|
-
if (!sFilePath) return void 0;
|
|
179
|
-
const oFile = await FileReader.loadModule(sFilePath, { default: true });
|
|
180
|
-
if (!Validator.isObject(oFile?.options)) throw new NanoError(`${sFilePath} must default export { options }: the Better Auth options.`);
|
|
181
|
-
return oFile.options;
|
|
182
|
-
}
|
|
183
|
-
/** The instance, once the app's plugin created it */
|
|
184
|
-
static get() {
|
|
185
|
-
return this.oAuth;
|
|
186
|
-
}
|
|
187
|
-
/** Creates the instance on the app's client and schema, its handler and the session lookup */
|
|
188
|
-
static async create(oOptions, oDb, oSchema) {
|
|
189
|
-
const { betterAuth } = await import("better-auth");
|
|
190
|
-
const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
|
|
191
|
-
const { getSchema } = await import("better-auth/db");
|
|
192
|
-
const { fromNodeHeaders, toNodeHandler } = await import("better-auth/node");
|
|
193
|
-
const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !oSchema[sModel]);
|
|
194
|
-
if (aMissing.length) throw new NanoError(`Better Auth needs the tables ${aMissing.join(", ")} in the app's schema. Run \`npx auth generate\` and apply your schema with drizzle-kit.`);
|
|
195
|
-
const oAuth = betterAuth({
|
|
196
|
-
...oOptions,
|
|
197
|
-
advanced: {
|
|
198
|
-
...oOptions.advanced,
|
|
199
|
-
ipAddress: {
|
|
200
|
-
trustedProxies: PRIVATE_NETWORKS,
|
|
201
|
-
...oOptions.advanced?.ipAddress
|
|
202
|
-
}
|
|
203
|
-
},
|
|
204
|
-
database: drizzleAdapter(oDb, {
|
|
205
|
-
provider: "pg",
|
|
206
|
-
schema: oSchema
|
|
207
|
-
})
|
|
208
|
-
});
|
|
209
|
-
this.oAuth = oAuth;
|
|
210
|
-
const fnSession = (oReq, oRes, fnNext) => oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
|
|
211
|
-
oRes.locals.session = oSession ?? void 0;
|
|
212
|
-
fnNext();
|
|
213
|
-
});
|
|
214
|
-
return {
|
|
215
|
-
oHandler: toNodeHandler(oAuth),
|
|
216
|
-
fnSession,
|
|
217
|
-
sPath: `${oOptions.basePath ?? "/api/auth"}/{*path}`
|
|
218
|
-
};
|
|
101
|
+
// ../core/utils/Paths.ts
|
|
102
|
+
/** Paths of the app's pages */
|
|
103
|
+
var Paths = class {
|
|
104
|
+
/** Whether a path matches a pattern: exact (`/admin`), or the path and all below it (`/admin/*`) */
|
|
105
|
+
static matches(sPattern, sPath) {
|
|
106
|
+
if (!sPattern.endsWith("/*")) return sPattern === sPath;
|
|
107
|
+
const sBase = sPattern.slice(0, -2);
|
|
108
|
+
return sPath === sBase || sPath.startsWith(`${sBase}/`);
|
|
219
109
|
}
|
|
220
110
|
};
|
|
111
|
+
// ../core/const.ts
|
|
112
|
+
/** Loopback and private networks: the proxies the backend trusts by default */
|
|
113
|
+
const PRIVATE_NETWORKS = [
|
|
114
|
+
"127.0.0.0/8",
|
|
115
|
+
"::1/128",
|
|
116
|
+
"10.0.0.0/8",
|
|
117
|
+
"172.16.0.0/12",
|
|
118
|
+
"192.168.0.0/16",
|
|
119
|
+
"fc00::/7"
|
|
120
|
+
];
|
|
121
|
+
// ../core/utils/validations.ts
|
|
122
|
+
/** A function of the consumer, typed as what the framework calls */
|
|
123
|
+
function callable() {
|
|
124
|
+
return z.custom((oValue) => typeof oValue === "function");
|
|
125
|
+
}
|
|
126
|
+
/** Permissions of Better Auth's admin plugin: the actions per resource */
|
|
127
|
+
const PERMISSIONS = z.record(z.string(), z.array(z.string())).optional();
|
|
128
|
+
/** A method of a route: its handler, or it with its own permissions */
|
|
129
|
+
const METHOD = z.union([callable(), z.object({
|
|
130
|
+
handler: callable(),
|
|
131
|
+
permissions: PERMISSIONS
|
|
132
|
+
})]).optional();
|
|
133
|
+
/** A `*.route.ts` file: its options, its callbacks and at least one HTTP method, nothing else */
|
|
134
|
+
const ROUTE = z.strictObject({
|
|
135
|
+
expression: z.string().optional(),
|
|
136
|
+
public: z.boolean().optional(),
|
|
137
|
+
permissions: PERMISSIONS,
|
|
138
|
+
callbacks: z.object({
|
|
139
|
+
onInit: callable().optional(),
|
|
140
|
+
onBefore: callable().optional()
|
|
141
|
+
}).optional(),
|
|
142
|
+
GET: METHOD,
|
|
143
|
+
POST: METHOD,
|
|
144
|
+
PUT: METHOD,
|
|
145
|
+
DELETE: METHOD,
|
|
146
|
+
QUERY: METHOD
|
|
147
|
+
}).refine(({ GET, POST, PUT, DELETE, QUERY }) => GET ?? POST ?? PUT ?? DELETE ?? QUERY, { error: "It defines no HTTP method" });
|
|
148
|
+
/** A path of the app, starting with `/` */
|
|
149
|
+
const PATH = z.string().startsWith("/");
|
|
150
|
+
z.object({
|
|
151
|
+
type: z.enum(["app", "api"]),
|
|
152
|
+
data: z.record(z.string(), z.any()).optional(),
|
|
153
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
154
|
+
auth: z.object({
|
|
155
|
+
path: z.string(),
|
|
156
|
+
routes: z.array(z.object({
|
|
157
|
+
match: PATH,
|
|
158
|
+
redirect: PATH.optional(),
|
|
159
|
+
roles: z.string().optional(),
|
|
160
|
+
permissions: z.record(z.string(), z.string()).optional()
|
|
161
|
+
}).refine(({ match, redirect }) => !Paths.matches(match, redirect ?? "/"), { error: "Its \"redirect\" must be outside its \"match\"" })).optional()
|
|
162
|
+
}).optional()
|
|
163
|
+
});
|
|
221
164
|
// src/auth/permissions.ts
|
|
222
165
|
/** Whether the session's user has the permissions; always without Better Auth or permissions */
|
|
223
166
|
async function isGranted({ auth, session }, oPermissions) {
|
|
@@ -257,9 +200,8 @@ var Endpoint = class {
|
|
|
257
200
|
createHandler(oParams) {
|
|
258
201
|
return (oReq, oRes, fnNext) => {
|
|
259
202
|
if (oParams.auth && !oRes.locals.session && !this.isPublic) return fnNext(new NanoError("Not authenticated", 401));
|
|
260
|
-
const sMethod = oReq.method
|
|
261
|
-
const
|
|
262
|
-
const oMethod = sHandled && this.oMethods[sHandled];
|
|
203
|
+
const sMethod = oReq.method;
|
|
204
|
+
const oMethod = this.oMethods[sMethod];
|
|
263
205
|
if (!oMethod) return fnNext(new NanoError(`Method ${sMethod} not implemented`, 405));
|
|
264
206
|
const oRequestParams = {
|
|
265
207
|
...oParams,
|
|
@@ -295,7 +237,7 @@ var Router = class {
|
|
|
295
237
|
async mount() {
|
|
296
238
|
if (!this.sRoutesDir) return [];
|
|
297
239
|
const oFiles = await FileLoader.findMany("route", this.sRoutesDir);
|
|
298
|
-
const oRoutes = Validator.validate(oFiles,
|
|
240
|
+
const oRoutes = Validator.validate(oFiles, ROUTE);
|
|
299
241
|
return Object.entries(oRoutes).map(([sFilePath, { expression, callbacks, public: isPublic, permissions, ...methods }]) => {
|
|
300
242
|
const sPath = this.createPath(sFilePath, expression);
|
|
301
243
|
Logger.info(sPath);
|
|
@@ -314,6 +256,58 @@ var Router = class {
|
|
|
314
256
|
return `${this.sBasePath}${sEndpoint.replace(/\/index$/, "")}${sExpression}` || "/";
|
|
315
257
|
}
|
|
316
258
|
};
|
|
259
|
+
// src/auth/BetterAuth.ts
|
|
260
|
+
/** Files of the app's Better Auth options, relative to `cwd`: source, then built */
|
|
261
|
+
const AUTH_FILES = ["src/auth.ts", "src/auth.js"];
|
|
262
|
+
/** Better Auth on the app's Drizzle client: the instance, its handler and the session lookup */
|
|
263
|
+
var BetterAuth = class {
|
|
264
|
+
static oAuth;
|
|
265
|
+
/** The options of the app's `src/auth.ts`, or undefined without it: Better Auth stays off */
|
|
266
|
+
static async findOptions() {
|
|
267
|
+
const sFilePath = AUTH_FILES.find(existsSync);
|
|
268
|
+
if (!sFilePath) return void 0;
|
|
269
|
+
const oFile = await FileReader.loadModule(sFilePath, { default: true });
|
|
270
|
+
if (!Validator.isObject(oFile?.options)) throw new NanoError(`${sFilePath} must default export { options }: the Better Auth options.`);
|
|
271
|
+
return oFile.options;
|
|
272
|
+
}
|
|
273
|
+
/** The instance, once the app's plugin created it */
|
|
274
|
+
static get() {
|
|
275
|
+
return this.oAuth;
|
|
276
|
+
}
|
|
277
|
+
/** Creates the instance on the app's client and schema, its handler and the session lookup */
|
|
278
|
+
static async create(oOptions, oDb, oSchema) {
|
|
279
|
+
const { betterAuth } = await import("better-auth");
|
|
280
|
+
const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
|
|
281
|
+
const { getSchema } = await import("better-auth/db");
|
|
282
|
+
const { fromNodeHeaders, toNodeHandler } = await import("better-auth/node");
|
|
283
|
+
const aMissing = Object.keys(getSchema(oOptions)).filter((sModel) => !oSchema[sModel]);
|
|
284
|
+
if (aMissing.length) throw new NanoError(`Better Auth needs the tables ${aMissing.join(", ")} in the app's schema. Run \`npx auth generate\` and apply your schema with drizzle-kit.`);
|
|
285
|
+
const oAuth = betterAuth({
|
|
286
|
+
...oOptions,
|
|
287
|
+
advanced: {
|
|
288
|
+
...oOptions.advanced,
|
|
289
|
+
ipAddress: {
|
|
290
|
+
trustedProxies: PRIVATE_NETWORKS,
|
|
291
|
+
...oOptions.advanced?.ipAddress
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
database: drizzleAdapter(oDb, {
|
|
295
|
+
provider: "pg",
|
|
296
|
+
schema: oSchema
|
|
297
|
+
})
|
|
298
|
+
});
|
|
299
|
+
this.oAuth = oAuth;
|
|
300
|
+
const fnSession = (oReq, oRes, fnNext) => oAuth.api.getSession({ headers: fromNodeHeaders(oReq.headers) }).then((oSession) => {
|
|
301
|
+
oRes.locals.session = oSession ?? void 0;
|
|
302
|
+
fnNext();
|
|
303
|
+
});
|
|
304
|
+
return {
|
|
305
|
+
oHandler: toNodeHandler(oAuth),
|
|
306
|
+
fnSession,
|
|
307
|
+
sPath: `${oOptions.basePath ?? "/api/auth"}/{*path}`
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
};
|
|
317
311
|
// src/database/Database.ts
|
|
318
312
|
/** Drizzle clients of the app and its plugins, one per name, on one pool of `DATABASE_URL` */
|
|
319
313
|
var Database = class {
|
|
@@ -324,17 +318,23 @@ var Database = class {
|
|
|
324
318
|
static get(sName) {
|
|
325
319
|
return this.oClients[sName];
|
|
326
320
|
}
|
|
327
|
-
/** Mounts the Drizzle client of a schema
|
|
321
|
+
/** Mounts the Drizzle client of a schema, checking the `nano_*` rule of its tables */
|
|
328
322
|
static async mount(sName, { schema }) {
|
|
329
323
|
if (this.get(sName)) throw new NanoError(`The database "${sName}" is already mounted.`);
|
|
330
324
|
const { defineRelations, is } = await import("drizzle-orm");
|
|
331
325
|
const { getTableConfig, PgTable } = await import("drizzle-orm/pg-core");
|
|
332
326
|
const { drizzle } = await import("drizzle-orm/node-postgres");
|
|
333
327
|
const sOwn = `nano_${sName}`;
|
|
334
|
-
const
|
|
328
|
+
const { breaks, rule } = sName === "app" ? {
|
|
329
|
+
breaks: (sSchema) => sSchema.startsWith("nano_"),
|
|
330
|
+
rule: "the nano_* schemas are reserved for plugins"
|
|
331
|
+
} : {
|
|
332
|
+
breaks: (sSchema) => sSchema !== sOwn,
|
|
333
|
+
rule: `a plugin keeps its tables in "${sOwn}"`
|
|
334
|
+
};
|
|
335
335
|
for (const oTable of Object.values(schema).filter((oValue) => is(oValue, PgTable))) {
|
|
336
336
|
const { name, schema: sSchema = "public" } = getTableConfig(oTable);
|
|
337
|
-
if (
|
|
337
|
+
if (breaks(sSchema)) throw new NanoError(`The database "${sName}": table "${sSchema}.${name}" breaks the rule, ${rule}.`);
|
|
338
338
|
}
|
|
339
339
|
this.oPool ??= await this.pool();
|
|
340
340
|
const oClient = drizzle({
|
|
@@ -357,19 +357,18 @@ var Database = class {
|
|
|
357
357
|
/** A group of routes, with its options, callbacks and database */
|
|
358
358
|
var Plugin = class {
|
|
359
359
|
oConfig;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
constructor(oConfig, oAuthOptions) {
|
|
360
|
+
/** Creates a new Plugin instance */
|
|
361
|
+
constructor(oConfig) {
|
|
363
362
|
this.oConfig = oConfig;
|
|
364
|
-
this.oAuthOptions = oAuthOptions;
|
|
365
363
|
}
|
|
366
364
|
/** Mounts its endpoints on express app */
|
|
367
365
|
async mount(oApp) {
|
|
368
366
|
const { name, routesDir, basePath, database, options, callbacks } = this.oConfig;
|
|
367
|
+
const oAuthOptions = name === "app" ? await BetterAuth.findOptions() : void 0;
|
|
369
368
|
const oDb = database ? await Database.mount(name, database) : Database.get("app");
|
|
370
|
-
if (
|
|
369
|
+
if (oAuthOptions) {
|
|
371
370
|
if (!database) throw new NanoError("src/auth.ts needs the database of the app: set mainPlugin.database.schema.");
|
|
372
|
-
const { sPath, oHandler, fnSession } = await BetterAuth.create(
|
|
371
|
+
const { sPath, oHandler, fnSession } = await BetterAuth.create(oAuthOptions, oDb, database.schema);
|
|
373
372
|
oApp.all(sPath, oHandler);
|
|
374
373
|
oApp.use(fnSession);
|
|
375
374
|
}
|
|
@@ -388,8 +387,6 @@ var Plugin = class {
|
|
|
388
387
|
const INITIAL_CONFIG = {
|
|
389
388
|
/** Application port */
|
|
390
389
|
port: 3e3,
|
|
391
|
-
/** Application listener */
|
|
392
|
-
listener: () => {},
|
|
393
390
|
/** Express settings: the client IP comes from `X-Forwarded-For` only through private proxies */
|
|
394
391
|
settings: { "trust proxy": PRIVATE_NETWORKS },
|
|
395
392
|
/** Initial middlewares */
|
|
@@ -449,7 +446,7 @@ function mergeConfig(oConfig = {}) {
|
|
|
449
446
|
};
|
|
450
447
|
}
|
|
451
448
|
// src/app/errors.ts
|
|
452
|
-
/** Database errors the request caused, by their Postgres code or Drizzle's name
|
|
449
|
+
/** Database errors the request caused, by their Postgres code or Drizzle's name */
|
|
453
450
|
const DATABASE_ERRORS = {
|
|
454
451
|
"23505": {
|
|
455
452
|
status: 409,
|
|
@@ -522,7 +519,7 @@ var NanoApp = class {
|
|
|
522
519
|
async start() {
|
|
523
520
|
try {
|
|
524
521
|
await this.initialize();
|
|
525
|
-
this.oApp.listen(
|
|
522
|
+
this.oApp.listen(this.oConfig.port, () => {
|
|
526
523
|
this.oConfig.listener?.();
|
|
527
524
|
Logger.info(`Nano App running on port ${this.oConfig.port}`);
|
|
528
525
|
});
|
|
@@ -535,20 +532,22 @@ var NanoApp = class {
|
|
|
535
532
|
async initialize() {
|
|
536
533
|
for (const [sKey, oValue] of Object.entries(this.oConfig.settings)) this.oApp.set(sKey, oValue);
|
|
537
534
|
this.setPackages(this.oConfig.packages);
|
|
535
|
+
const { mainPlugin, plugins } = this.oConfig;
|
|
538
536
|
const oPlugins = {
|
|
539
537
|
before: [],
|
|
540
538
|
after: []
|
|
541
539
|
};
|
|
542
|
-
for (const oPlugin of
|
|
543
|
-
for (const oPlugin of
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
540
|
+
for (const oPlugin of plugins) oPlugins[oPlugin.stage ?? "before"].push(oPlugin);
|
|
541
|
+
for (const oPlugin of [
|
|
542
|
+
...oPlugins.before,
|
|
543
|
+
mainPlugin,
|
|
544
|
+
...oPlugins.after
|
|
545
|
+
]) await new Plugin(oPlugin).mount(this.oApp);
|
|
547
546
|
this.oApp.use(notFoundHandler);
|
|
548
547
|
this.oApp.use(errorHandler);
|
|
549
548
|
}
|
|
550
549
|
/** Adds the packages according to the configuration */
|
|
551
|
-
setPackages(oConfig
|
|
550
|
+
setPackages(oConfig) {
|
|
552
551
|
if (oConfig.morgan) {
|
|
553
552
|
for (const [sKey, fnToken] of Object.entries(oConfig.morgan.tokens ?? {})) morgan.token(sKey, fnToken);
|
|
554
553
|
this.oApp.use(morgan(oConfig.morgan.format));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nano-app/backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Express server with file-convention routes, plugins and an optional PostgreSQL layer",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"glob": "^14.0.4",
|
|
51
51
|
"helmet": "^8.3.0",
|
|
52
52
|
"jiti": "^2.7.0",
|
|
53
|
-
"morgan": "^1.12.1"
|
|
53
|
+
"morgan": "^1.12.1",
|
|
54
|
+
"zod": "^4.6.7"
|
|
54
55
|
},
|
|
55
56
|
"peerDependencies": {
|
|
56
57
|
"better-auth": "^1.7.18",
|