create-nexwire 4.2.1 → 4.2.3
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/express/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
- package/express/src/database/migrations/sqlite/meta/_journal.json +1 -1
- package/express/src/framework/database/app-schema.ts +37 -0
- package/express/src/framework/database/connection.ts +198 -196
- package/express/src/framework/database/seed.ts +35 -22
- package/express/src/framework/facade.ts +24 -5
- package/express/src/framework/kernel.ts +54 -52
- package/express/src/framework/maker-cli/index.mjs +2 -0
- package/express/src/framework/maker-cli/module/core.mjs +30 -0
- package/express/src/framework/maker-cli/module/index.mjs +10 -1
- package/express/src/framework/maker-cli/provider/core.mjs +49 -0
- package/express/src/framework/maker-cli/provider/index.mjs +12 -0
- package/express/src/framework/maker-cli/stubs/notification/controller.ts.stub +77 -77
- package/express/src/framework/maker-cli/stubs/provider/module-barrel.ts.stub +19 -0
- package/express/src/framework/maker-cli/stubs/provider/module.ts.stub +21 -0
- package/express/src/framework/maker-cli/stubs/provider/name.ts.stub +21 -0
- package/express/src/framework/maker-cli/utils/help.mjs +6 -1
- package/express/src/framework/maker-cli/utils/project-commands.mjs +73 -12
- package/express/src/framework/notification/index.ts +63 -7
- package/express/src/framework/providers/adapter.ts +125 -0
- package/express/src/framework/providers/contracts.ts +56 -0
- package/express/src/framework/providers/loader.ts +105 -0
- package/express/src/framework/providers/registry.ts +62 -0
- package/express/src/middlewares/auth-middleware.ts +65 -38
- package/express/src/providers/README.md +107 -0
- package/express/src/storage/database/nexwire.sqlite +0 -0
- package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +1 -1
- package/hono/src/database/migrations/sqlite/meta/_journal.json +1 -1
- package/hono/src/framework/database/app-schema.ts +37 -0
- package/hono/src/framework/database/connection.ts +198 -196
- package/hono/src/framework/database/seed.ts +35 -22
- package/hono/src/framework/facade.ts +48 -29
- package/hono/src/framework/kernel.ts +50 -48
- package/hono/src/framework/maker-cli/index.mjs +2 -0
- package/hono/src/framework/maker-cli/module/core.mjs +30 -0
- package/hono/src/framework/maker-cli/module/index.mjs +10 -1
- package/hono/src/framework/maker-cli/provider/core.mjs +49 -0
- package/hono/src/framework/maker-cli/provider/index.mjs +12 -0
- package/hono/src/framework/maker-cli/stubs/notification/controller.ts.stub +85 -85
- package/hono/src/framework/maker-cli/stubs/provider/module-barrel.ts.stub +19 -0
- package/hono/src/framework/maker-cli/stubs/provider/module.ts.stub +21 -0
- package/hono/src/framework/maker-cli/stubs/provider/name.ts.stub +21 -0
- package/hono/src/framework/maker-cli/utils/help.mjs +6 -1
- package/hono/src/framework/maker-cli/utils/project-commands.mjs +73 -12
- package/hono/src/framework/notification/index.ts +63 -7
- package/hono/src/framework/providers/adapter.ts +125 -0
- package/hono/src/framework/providers/contracts.ts +56 -0
- package/hono/src/framework/providers/loader.ts +105 -0
- package/hono/src/framework/providers/registry.ts +62 -0
- package/hono/src/middlewares/auth-middleware.ts +65 -38
- package/hono/src/providers/README.md +107 -0
- package/hono/src/storage/database/nexwire.sqlite +0 -0
- package/package.json +2 -2
- package/src/index.js +14 -6
|
@@ -13,8 +13,16 @@ import { PROJECT_COMMAND_FLAG } from "./help.mjs";
|
|
|
13
13
|
* there would be overwritten on the next framework update - and `src/modules/`
|
|
14
14
|
* is swept by the route, job and seeder globs. The project root is the one
|
|
15
15
|
* place nothing else owns.
|
|
16
|
+
*
|
|
17
|
+
* Two names are read: commands.mjs (plain JavaScript, loaded by plain node)
|
|
18
|
+
* and commands.ts (TypeScript, loaded through tsx). The .mjs is the incumbent
|
|
19
|
+
* and wins if a project keeps both.
|
|
16
20
|
*/
|
|
17
21
|
export const PROJECT_COMMANDS_PATH = "maker/commands.mjs";
|
|
22
|
+
export const PROJECT_COMMANDS_TS_PATH = "maker/commands.ts";
|
|
23
|
+
|
|
24
|
+
/** Process-global so a re-evaluated module cannot register tsx twice. */
|
|
25
|
+
const TSX_REGISTERED = "__nexwireMakerTsxRegistered";
|
|
18
26
|
|
|
19
27
|
const PROJECT_COMMANDS_TEMPLATE = `/**
|
|
20
28
|
* Your project's maker commands.
|
|
@@ -36,8 +44,11 @@ const PROJECT_COMMANDS_TEMPLATE = `/**
|
|
|
36
44
|
* which one. Rename yours; the built-in stays.
|
|
37
45
|
* - An error in this file is reported, never swallowed. If your commands stop
|
|
38
46
|
* appearing, the fault is here rather than in your command code.
|
|
39
|
-
* -
|
|
40
|
-
*
|
|
47
|
+
* - This file runs as plain JavaScript under plain Node, so it cannot import
|
|
48
|
+
* your TypeScript source or use your tsconfig path alias. If a command
|
|
49
|
+
* needs either, name this file maker/commands.ts instead and delete this
|
|
50
|
+
* one - nexwire loads that file through tsx, with your tsconfig applied.
|
|
51
|
+
* For editor type support, create maker/tsconfig.json as shown in the docs.
|
|
41
52
|
*
|
|
42
53
|
* Replace or delete the example below once you have a command of your own.
|
|
43
54
|
*/
|
|
@@ -54,14 +65,47 @@ export default function register(program) {
|
|
|
54
65
|
}
|
|
55
66
|
`;
|
|
56
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Why: Plain node cannot import TypeScript, so maker/commands.ts needs tsx
|
|
70
|
+
* registered before it is imported. Registered at most once per process
|
|
71
|
+
* and never unregistered: two overlapping registrations corrupt the
|
|
72
|
+
* loader, and commands routinely import more TypeScript at action time,
|
|
73
|
+
* long after this function returns. The flag lives on globalThis because
|
|
74
|
+
* this module can be evaluated more than once in one process (vitest's
|
|
75
|
+
* per-file isolation) while the loader hooks are process-wide.
|
|
76
|
+
* When: Only when the project's file is TypeScript - maker/commands.mjs never
|
|
77
|
+
* pays for tsx being installed.
|
|
78
|
+
* Where: utils/project-commands.mjs.
|
|
79
|
+
*/
|
|
80
|
+
async function registerTsLoader(rel) {
|
|
81
|
+
if (globalThis[TSX_REGISTERED]) return;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const { register } = await import("tsx/esm/api");
|
|
85
|
+
register();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`${rel} needs tsx to load TypeScript, but it could not be registered: ${error?.message || error}. tsx ships as a devDependency of every nexwire project - run npm install, or rename the file to ${PROJECT_COMMANDS_PATH}.`,
|
|
89
|
+
{ cause: error }
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
globalThis[TSX_REGISTERED] = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
57
96
|
/**
|
|
58
97
|
* Why: Registers the commands a project defined for itself.
|
|
59
98
|
* When: Every maker invocation, right after the framework's own registrars.
|
|
60
99
|
* Where: Maker CLI entry flow, from utils/project-commands.mjs.
|
|
61
|
-
* How: Imports maker/commands.mjs
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
100
|
+
* How: Imports maker/commands.mjs - or maker/commands.ts, loaded through tsx,
|
|
101
|
+
* which resolves the project's tsconfig path aliases - resolved from
|
|
102
|
+
* process.cwd(), not from import.meta.dirname, which points inside
|
|
103
|
+
* node_modules and would find the framework's own directory instead of the
|
|
104
|
+
* project. If a project keeps both files the .mjs wins and the .ts file is
|
|
105
|
+
* skipped with a warning naming it, so neither file is ever silently dead:
|
|
106
|
+
* a project that already runs must not be re-registered by a file that
|
|
107
|
+
* appeared later, and a file that is skipped must say so. Absent is silent
|
|
108
|
+
* and free; broken is loud, because a developer's commands silently not
|
|
65
109
|
* existing is a far worse experience than an error message. A name that
|
|
66
110
|
* collides with a framework command is refused by commander itself, which
|
|
67
111
|
* is the right outcome: the built-in wins and the project cannot shadow a
|
|
@@ -70,10 +114,23 @@ export default function register(program) {
|
|
|
70
114
|
* asked for the name.
|
|
71
115
|
*/
|
|
72
116
|
export async function registerProjectCommands(program, rawArgs, cwd = process.cwd()) {
|
|
73
|
-
const
|
|
117
|
+
const mjsFile = path.resolve(cwd, PROJECT_COMMANDS_PATH);
|
|
118
|
+
const tsFile = path.resolve(cwd, PROJECT_COMMANDS_TS_PATH);
|
|
119
|
+
const hasMjs = fs.existsSync(mjsFile);
|
|
120
|
+
const hasTs = fs.existsSync(tsFile);
|
|
121
|
+
|
|
122
|
+
if (!hasMjs && !hasTs) {
|
|
123
|
+
return { loaded: false, file: mjsFile, added: [] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const isTs = !hasMjs && hasTs;
|
|
127
|
+
const file = isTs ? tsFile : mjsFile;
|
|
128
|
+
const rel = isTs ? PROJECT_COMMANDS_TS_PATH : PROJECT_COMMANDS_PATH;
|
|
74
129
|
|
|
75
|
-
if (
|
|
76
|
-
|
|
130
|
+
if (hasMjs && hasTs) {
|
|
131
|
+
console.warn(
|
|
132
|
+
`${PROJECT_COMMANDS_TS_PATH} is ignored because ${PROJECT_COMMANDS_PATH} exists. Keep one - delete the file you are not using.`
|
|
133
|
+
);
|
|
77
134
|
}
|
|
78
135
|
|
|
79
136
|
// Not wrapped in a bare try/catch on purpose. A file that exists but fails to
|
|
@@ -81,18 +138,22 @@ export async function registerProjectCommands(program, rawArgs, cwd = process.cw
|
|
|
81
138
|
// command that is actually in this loader. The catches below only attach the
|
|
82
139
|
// file name, because an error from inside the project file otherwise reads as
|
|
83
140
|
// if it came from the framework.
|
|
141
|
+
if (isTs) {
|
|
142
|
+
await registerTsLoader(rel);
|
|
143
|
+
}
|
|
144
|
+
|
|
84
145
|
let module;
|
|
85
146
|
try {
|
|
86
147
|
module = await import(pathToFileURL(file).href);
|
|
87
148
|
} catch (error) {
|
|
88
|
-
throw new Error(`Could not load ${
|
|
149
|
+
throw new Error(`Could not load ${rel}: ${error?.message || error}`, { cause: error });
|
|
89
150
|
}
|
|
90
151
|
|
|
91
152
|
const register = module.default ?? module.register;
|
|
92
153
|
|
|
93
154
|
if (typeof register !== "function") {
|
|
94
155
|
throw new TypeError(
|
|
95
|
-
`${
|
|
156
|
+
`${rel} must export a default function (program, args) => void. Found: ${typeof register}.`
|
|
96
157
|
);
|
|
97
158
|
}
|
|
98
159
|
|
|
@@ -101,7 +162,7 @@ export async function registerProjectCommands(program, rawArgs, cwd = process.cw
|
|
|
101
162
|
try {
|
|
102
163
|
await register(program, rawArgs);
|
|
103
164
|
} catch (error) {
|
|
104
|
-
throw new Error(`Could not register commands from ${
|
|
165
|
+
throw new Error(`Could not register commands from ${rel}: ${error?.message || error}`, {
|
|
105
166
|
cause: error
|
|
106
167
|
});
|
|
107
168
|
}
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import { eq } from "drizzle-orm";
|
|
2
2
|
import { db } from "@/framework/database/connection.js";
|
|
3
3
|
import { dispatchEvent } from "@/framework/events/dispatcher.js";
|
|
4
|
+
import { resolveAuthProvider } from "@/framework/providers/adapter.js";
|
|
4
5
|
import { redisClientIfReady } from "@/framework/redis/client.js";
|
|
5
6
|
import { mail } from "@/framework/support/mail.js";
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
let notifications: any = null;
|
|
9
|
+
let modelsLoaded = false;
|
|
10
|
+
let modelsLoadAttempted = false;
|
|
11
|
+
|
|
12
|
+
async function loadNotificationModel() {
|
|
13
|
+
if (modelsLoaded) return true;
|
|
14
|
+
if (modelsLoadAttempted) return false;
|
|
15
|
+
modelsLoadAttempted = true;
|
|
16
|
+
try {
|
|
17
|
+
const notificationMod = await import("@/modules/auth/database/models/notification.js").catch(() => null);
|
|
18
|
+
if (notificationMod?.notifications) notifications = notificationMod.notifications;
|
|
19
|
+
modelsLoaded = !!notifications;
|
|
20
|
+
return modelsLoaded;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
8
25
|
|
|
9
26
|
/** Extra JSON metadata attached to the notification (e.g. entity IDs, links). */
|
|
10
27
|
export type NotificationOptions = {
|
|
@@ -22,6 +39,8 @@ export type NotificationOptions = {
|
|
|
22
39
|
broadcast?: boolean;
|
|
23
40
|
/** Email delivery options */
|
|
24
41
|
mail?: {
|
|
42
|
+
/** Explicit recipient. Skips the auth provider contact lookup. */
|
|
43
|
+
to?: string;
|
|
25
44
|
subject?: string;
|
|
26
45
|
html?: string;
|
|
27
46
|
};
|
|
@@ -29,6 +48,13 @@ export type NotificationOptions = {
|
|
|
29
48
|
|
|
30
49
|
/** Create a notification and persist it to the database. Optionally broadcast via Socket.IO and/or send by email. */
|
|
31
50
|
export async function notify(userId: number, options: NotificationOptions) {
|
|
51
|
+
const ok = await loadNotificationModel();
|
|
52
|
+
if (!ok || !notifications) {
|
|
53
|
+
console.warn(
|
|
54
|
+
"[notification] skipped: notification model not available (@/modules/auth/database/models/notification.js). Delete or replace auth/notification without breaking framework."
|
|
55
|
+
);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
32
58
|
const insertResult = await db.insert(notifications).values({
|
|
33
59
|
userId,
|
|
34
60
|
type: options.type || "info",
|
|
@@ -63,9 +89,39 @@ async function sendMailChannel(
|
|
|
63
89
|
notification: { id: number; title: string; body: string | null },
|
|
64
90
|
options: NotificationOptions
|
|
65
91
|
) {
|
|
66
|
-
|
|
92
|
+
let to: string | null = (options.mail as any)?.to || null;
|
|
93
|
+
let name: string | null = null;
|
|
94
|
+
|
|
95
|
+
if (!to) {
|
|
96
|
+
try {
|
|
97
|
+
const provider = await resolveAuthProvider();
|
|
98
|
+
if (provider?.getUserContact) {
|
|
99
|
+
const contact = await provider.getUserContact(userId);
|
|
100
|
+
if (contact?.email) {
|
|
101
|
+
to = contact.email as string;
|
|
102
|
+
name = (contact.name as string) || null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!to) {
|
|
109
|
+
try {
|
|
110
|
+
const userMod = await import("@/modules/auth/database/models/user.js").catch(() => null);
|
|
111
|
+
if (userMod?.users) {
|
|
112
|
+
const [user] = await db
|
|
113
|
+
.select({ email: userMod.users.email, name: (userMod.users as any).name })
|
|
114
|
+
.from(userMod.users)
|
|
115
|
+
.where(eq(userMod.users.id, userId));
|
|
116
|
+
if (user?.email) {
|
|
117
|
+
to = user.email as string;
|
|
118
|
+
name = (user as any).name || null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
67
123
|
|
|
68
|
-
if (!
|
|
124
|
+
if (!to) return;
|
|
69
125
|
|
|
70
126
|
const subject = options.mail?.subject || notification.title;
|
|
71
127
|
const html = options.mail?.html || defaultMailHtml(notification.title, notification.body);
|
|
@@ -74,14 +130,14 @@ async function sendMailChannel(
|
|
|
74
130
|
await dispatchEvent(
|
|
75
131
|
"notification:mail",
|
|
76
132
|
{
|
|
77
|
-
to
|
|
133
|
+
to,
|
|
78
134
|
subject,
|
|
79
135
|
html
|
|
80
136
|
},
|
|
81
137
|
{ queue: "mail" }
|
|
82
138
|
);
|
|
83
139
|
} else {
|
|
84
|
-
await mail.sendMail({ to
|
|
140
|
+
await mail.sendMail({ to, subject, html });
|
|
85
141
|
}
|
|
86
142
|
}
|
|
87
143
|
|
|
@@ -95,7 +151,7 @@ function escapeHtml(str: string) {
|
|
|
95
151
|
}
|
|
96
152
|
|
|
97
153
|
/** Convert a DB row into the normalized response shape, parsing the `data` JSON column. */
|
|
98
|
-
function normalize(row:
|
|
154
|
+
function normalize(row: any) {
|
|
99
155
|
let parsed: unknown = null;
|
|
100
156
|
if (row.data) {
|
|
101
157
|
try {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { db } from "@/framework/database/connection.js";
|
|
3
|
+
import type { AuthProvider } from "./contracts.js";
|
|
4
|
+
import { loadProviders } from "./loader.js";
|
|
5
|
+
import { getAuthProvider } from "./registry.js";
|
|
6
|
+
|
|
7
|
+
let defaultProvider: AuthProvider | null = null;
|
|
8
|
+
let defaultLoadAttempted = false;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Why: Builds the built-in auth provider from the default auth module.
|
|
12
|
+
* When: No custom provider was registered by the application.
|
|
13
|
+
* Where: Auth provider adapter.
|
|
14
|
+
* How: Lazily imports the default user/refresh-token models. When the default
|
|
15
|
+
* auth module was deleted or replaced, no provider is produced.
|
|
16
|
+
*/
|
|
17
|
+
async function loadDefaultProvider(): Promise<AuthProvider | null> {
|
|
18
|
+
if (defaultProvider) return defaultProvider;
|
|
19
|
+
if (defaultLoadAttempted) return null;
|
|
20
|
+
defaultLoadAttempted = true;
|
|
21
|
+
try {
|
|
22
|
+
const userMod = await import("@/modules/auth/database/models/user.js").catch(() => null);
|
|
23
|
+
if (!userMod?.users || !userMod?.refreshTokens) return null;
|
|
24
|
+
|
|
25
|
+
const users = userMod.users;
|
|
26
|
+
const refreshTokens = userMod.refreshTokens;
|
|
27
|
+
|
|
28
|
+
defaultProvider = {
|
|
29
|
+
async getUserById(id: number) {
|
|
30
|
+
try {
|
|
31
|
+
const user = await (db.query.users.findFirst as any)?.({
|
|
32
|
+
where: eq(users.id, id),
|
|
33
|
+
with: { role: true }
|
|
34
|
+
});
|
|
35
|
+
if (user) return user;
|
|
36
|
+
} catch {}
|
|
37
|
+
try {
|
|
38
|
+
const rows = await db.select().from(users).where(eq(users.id, id));
|
|
39
|
+
return rows?.[0] ?? null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
async findRefreshToken(jti: string) {
|
|
45
|
+
try {
|
|
46
|
+
const token = await (db.query.refreshTokens.findFirst as any)?.({
|
|
47
|
+
where: eq(refreshTokens.jti, jti)
|
|
48
|
+
});
|
|
49
|
+
if (token) return token;
|
|
50
|
+
} catch {}
|
|
51
|
+
try {
|
|
52
|
+
const rows = await db.select().from(refreshTokens).where(eq(refreshTokens.jti, jti));
|
|
53
|
+
return rows?.[0] ?? null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
async deleteRefreshToken(tokenId: number) {
|
|
59
|
+
try {
|
|
60
|
+
await db.delete(refreshTokens).where(eq(refreshTokens.id, tokenId));
|
|
61
|
+
} catch {}
|
|
62
|
+
},
|
|
63
|
+
async revokeRefreshToken(tokenId: number) {
|
|
64
|
+
try {
|
|
65
|
+
await db.delete(refreshTokens).where(eq(refreshTokens.id, tokenId));
|
|
66
|
+
} catch {}
|
|
67
|
+
},
|
|
68
|
+
async getAuthContext(user: any, payload?: any) {
|
|
69
|
+
try {
|
|
70
|
+
if (user?.role && typeof user.role === "object") {
|
|
71
|
+
return {
|
|
72
|
+
...(payload || {}),
|
|
73
|
+
id: user.id,
|
|
74
|
+
email: user.email,
|
|
75
|
+
roleId: user.role?.id ?? null,
|
|
76
|
+
role: user.role?.name ?? null,
|
|
77
|
+
...user
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
return {
|
|
82
|
+
...(payload || {}),
|
|
83
|
+
id: user.id,
|
|
84
|
+
email: user.email,
|
|
85
|
+
roleId: user.roleId ?? null,
|
|
86
|
+
role: user.role ?? null,
|
|
87
|
+
...user
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
async getUserContact(userId: number) {
|
|
91
|
+
try {
|
|
92
|
+
const user = await this.getUserById(userId);
|
|
93
|
+
if (!user) return null;
|
|
94
|
+
return {
|
|
95
|
+
email: user.email ?? null,
|
|
96
|
+
name: user.name ?? user.fullName ?? user.username ?? null,
|
|
97
|
+
phone: user.phone ?? user.phoneNumber ?? null
|
|
98
|
+
};
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
return defaultProvider;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Why: Resolves the auth provider the framework should use.
|
|
112
|
+
* When: The auth middleware or the notification mailer needs user data.
|
|
113
|
+
* Where: Auth provider adapter.
|
|
114
|
+
* How: Application providers are loaded first, so a file in `src/providers/`
|
|
115
|
+
* wins. Otherwise the built-in provider from the default auth module is
|
|
116
|
+
* used. When neither exists the framework degrades gracefully.
|
|
117
|
+
*/
|
|
118
|
+
export async function resolveAuthProvider(): Promise<AuthProvider | null> {
|
|
119
|
+
await loadProviders();
|
|
120
|
+
const registered = getAuthProvider();
|
|
121
|
+
if (registered) return registered;
|
|
122
|
+
const def = await loadDefaultProvider();
|
|
123
|
+
if (def) return def;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why: Defines the shapes a pluggable provider has to satisfy.
|
|
3
|
+
* When: A developer replaces a framework default (auth today, payments later).
|
|
4
|
+
* Where: Imported through the framework facade, never from framework internals.
|
|
5
|
+
* How: Interfaces stay deliberately loose so custom user shapes pass through.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Registry key of the built-in auth provider contract. */
|
|
9
|
+
export const AUTH_PROVIDER_KEY = "auth";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Registry key for any provider implementation.
|
|
13
|
+
*
|
|
14
|
+
* `string & {}` keeps the literal keys above discoverable by editors while
|
|
15
|
+
* still accepting any custom key, so a project can register its own contracts
|
|
16
|
+
* (payments, otp, storage, ...) without the framework having to know them.
|
|
17
|
+
*/
|
|
18
|
+
export type ProviderKey = typeof AUTH_PROVIDER_KEY | (string & {});
|
|
19
|
+
|
|
20
|
+
export interface AuthProviderUser {
|
|
21
|
+
id: number;
|
|
22
|
+
email?: string | null;
|
|
23
|
+
[key: string]: any;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AuthProviderRefreshToken {
|
|
27
|
+
id: number;
|
|
28
|
+
jti: string;
|
|
29
|
+
revoked?: boolean | null;
|
|
30
|
+
expiresAt?: Date | string | null;
|
|
31
|
+
userId?: number | null;
|
|
32
|
+
[key: string]: any;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AuthUserContext {
|
|
36
|
+
id: number;
|
|
37
|
+
email?: string | null;
|
|
38
|
+
roleId?: number | null;
|
|
39
|
+
role?: string | null;
|
|
40
|
+
[key: string]: any;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AuthUserContact {
|
|
44
|
+
email?: string | null;
|
|
45
|
+
name?: string | null;
|
|
46
|
+
phone?: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AuthProvider {
|
|
50
|
+
getUserById(id: number): Promise<AuthProviderUser | null>;
|
|
51
|
+
findRefreshToken(jti: string): Promise<AuthProviderRefreshToken | null>;
|
|
52
|
+
revokeRefreshToken?(tokenId: number): Promise<void>;
|
|
53
|
+
deleteRefreshToken?(tokenId: number): Promise<void>;
|
|
54
|
+
getAuthContext?(user: AuthProviderUser, payload?: any): Promise<AuthUserContext | any>;
|
|
55
|
+
getUserContact?(userId: number): Promise<AuthUserContact | null>;
|
|
56
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { glob } from "glob";
|
|
5
|
+
import { discoverModuleFiles } from "@/framework/modules/discover.js";
|
|
6
|
+
import { logger } from "@/framework/support/logger.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Why: Finds the app-level provider folder for the running code.
|
|
10
|
+
* When: Providers are loaded at boot.
|
|
11
|
+
* Where: Provider loader.
|
|
12
|
+
* How: Mirrors the module discovery rule — `src/providers` in development,
|
|
13
|
+
* `dist/src/providers` when the compiled server is the entrypoint.
|
|
14
|
+
*/
|
|
15
|
+
function resolveProvidersPath() {
|
|
16
|
+
const cwd = process.cwd();
|
|
17
|
+
const srcProviders = path.resolve(cwd, "src/providers");
|
|
18
|
+
const distProviders = path.resolve(cwd, "dist/src/providers");
|
|
19
|
+
const entry = String(process.argv[1] || "");
|
|
20
|
+
const runningDist = /[\\/]dist[\\/]/.test(entry);
|
|
21
|
+
|
|
22
|
+
if (runningDist && fs.existsSync(distProviders)) return distProviders;
|
|
23
|
+
return srcProviders;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const providersPath = resolveProvidersPath();
|
|
27
|
+
|
|
28
|
+
let loadPromise: Promise<number> | null = null;
|
|
29
|
+
let loading = false;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Why: Collects every provider file that should run at boot.
|
|
33
|
+
* When: The loader runs.
|
|
34
|
+
* Where: Provider loader.
|
|
35
|
+
* How: Globs `src/providers/**` for app-wide providers, and
|
|
36
|
+
* `src/modules/**\/providers.ts` plus `src/modules/**\/providers/*.ts` for
|
|
37
|
+
* module-scoped ones. Tests and declaration files are ignored.
|
|
38
|
+
*/
|
|
39
|
+
async function collectProviderFiles(): Promise<string[]> {
|
|
40
|
+
const files: string[] = [];
|
|
41
|
+
|
|
42
|
+
if (fs.existsSync(providersPath)) {
|
|
43
|
+
const appFiles = await glob("**/*.{ts,js,mjs}", {
|
|
44
|
+
cwd: providersPath,
|
|
45
|
+
absolute: true,
|
|
46
|
+
nodir: true,
|
|
47
|
+
ignore: ["**/__tests__/**", "**/*.d.ts", "**/*.test.*", "**/*.spec.*"],
|
|
48
|
+
windowsPathsNoEscape: true
|
|
49
|
+
});
|
|
50
|
+
files.push(...appFiles);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// A module plugs its own implementation in either from a single file
|
|
55
|
+
// (providers.ts) or from a folder (providers/*.ts). Both are imported.
|
|
56
|
+
const moduleFiles = await discoverModuleFiles("**/providers.{ts,js}");
|
|
57
|
+
const moduleProviderFiles = await discoverModuleFiles("**/providers/*.{ts,js}");
|
|
58
|
+
files.push(...moduleFiles, ...moduleProviderFiles);
|
|
59
|
+
} catch {}
|
|
60
|
+
|
|
61
|
+
return [...new Set(files)].sort();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Why: Imports each discovered provider file so its registration runs.
|
|
66
|
+
* When: The loader runs.
|
|
67
|
+
* Where: Provider loader.
|
|
68
|
+
* How: Imports in sorted order; a broken file is logged and skipped instead of
|
|
69
|
+
* taking the whole application down.
|
|
70
|
+
*/
|
|
71
|
+
async function loadProviderFiles(): Promise<number> {
|
|
72
|
+
const files = await collectProviderFiles();
|
|
73
|
+
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
try {
|
|
76
|
+
await import(pathToFileURL(file).href);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
logger.warn("Provider file failed to load", {
|
|
79
|
+
file,
|
|
80
|
+
error: error instanceof Error ? error.message : String(error)
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return files.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Why: Runs the application's provider files exactly once.
|
|
90
|
+
* When: The kernel boots, and on the first provider lookup.
|
|
91
|
+
* Where: Kernel startup and the auth provider adapter.
|
|
92
|
+
* How: Caches the promise so repeated lookups cost nothing. A provider file
|
|
93
|
+
* that itself asks for a provider during load gets an immediate answer
|
|
94
|
+
* instead of deadlocking on its own load.
|
|
95
|
+
*/
|
|
96
|
+
export async function loadProviders(): Promise<number> {
|
|
97
|
+
if (loading) return 0;
|
|
98
|
+
if (!loadPromise) {
|
|
99
|
+
loading = true;
|
|
100
|
+
loadPromise = loadProviderFiles().finally(() => {
|
|
101
|
+
loading = false;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return loadPromise;
|
|
105
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AUTH_PROVIDER_KEY, type AuthProvider, type ProviderKey } from "./contracts.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Why: Holds the app-wide provider implementations in one place.
|
|
5
|
+
* When: A provider file in `src/providers/` (or a module's `providers.ts`)
|
|
6
|
+
* registers its implementation at boot.
|
|
7
|
+
* Where: Read by the framework through `resolve*Provider()` helpers.
|
|
8
|
+
* How: A plain key/value map. No container, no decorators, no auto-wiring.
|
|
9
|
+
*/
|
|
10
|
+
const registry = new Map<string, unknown>();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Why: Registers a provider implementation under a key.
|
|
14
|
+
* When: A provider file runs at boot and claims a contract.
|
|
15
|
+
* Where: Application code, through the framework facade.
|
|
16
|
+
* How: Stores the implementation and returns it so it can be inlined.
|
|
17
|
+
*/
|
|
18
|
+
export function registerProvider<T>(key: ProviderKey, implementation: T): T {
|
|
19
|
+
registry.set(key, implementation);
|
|
20
|
+
return implementation;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Returns the registered implementation for a key, or null when absent. */
|
|
24
|
+
export function getProvider<T = unknown>(key: ProviderKey): T | null {
|
|
25
|
+
const found = registry.get(key);
|
|
26
|
+
return (found as T) ?? null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Reports whether a provider is registered for a key. */
|
|
30
|
+
export function hasProvider(key: ProviderKey): boolean {
|
|
31
|
+
return registry.has(key);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Removes a registered provider. Mainly useful in tests. */
|
|
35
|
+
export function clearProvider(key: ProviderKey): void {
|
|
36
|
+
registry.delete(key);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Lists every registered provider key, sorted for stable output. */
|
|
40
|
+
export function listProviders(): string[] {
|
|
41
|
+
return [...registry.keys()].sort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Registers the auth provider that the framework's auth middleware uses. */
|
|
45
|
+
export function registerAuthProvider(provider: AuthProvider): AuthProvider {
|
|
46
|
+
return registerProvider(AUTH_PROVIDER_KEY, provider);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Returns the registered auth provider, or null when none was registered. */
|
|
50
|
+
export function getAuthProvider(): AuthProvider | null {
|
|
51
|
+
return getProvider<AuthProvider>(AUTH_PROVIDER_KEY);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reports whether a custom auth provider is registered. */
|
|
55
|
+
export function hasAuthProvider(): boolean {
|
|
56
|
+
return hasProvider(AUTH_PROVIDER_KEY);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Removes the registered auth provider. Mainly useful in tests. */
|
|
60
|
+
export function clearAuthProvider(): void {
|
|
61
|
+
clearProvider(AUTH_PROVIDER_KEY);
|
|
62
|
+
}
|