create-efc-app 0.7.0 → 0.8.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 +14 -1
- package/dist/index.js +1280 -322
- package/dist/index.js.map +1 -1
- package/package.json +10 -1
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import * as p from "@clack/prompts";
|
|
5
5
|
import pc from "picocolors";
|
|
6
6
|
import { spawn } from "child_process";
|
|
7
|
+
import path3 from "path";
|
|
7
8
|
|
|
8
9
|
// src/scaffold.ts
|
|
9
10
|
import fs from "fs-extra";
|
|
@@ -30,12 +31,85 @@ var NO_USER_FEATURES = {
|
|
|
30
31
|
apiAndBilling: false,
|
|
31
32
|
support: false
|
|
32
33
|
};
|
|
34
|
+
function usesOAuth(opts) {
|
|
35
|
+
return !!(opts.oauth?.google || opts.oauth?.apple);
|
|
36
|
+
}
|
|
37
|
+
function principals(opts) {
|
|
38
|
+
return {
|
|
39
|
+
user: !!opts.registry || opts.userPortal,
|
|
40
|
+
admin: !!opts.registry || opts.adminPortal
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function sendsMail(opts) {
|
|
44
|
+
return !!opts.registry || opts.mailer;
|
|
45
|
+
}
|
|
46
|
+
function usesTables(opts) {
|
|
47
|
+
return opts.database === "postgresql" && opts.modelApi !== "defineModel";
|
|
48
|
+
}
|
|
49
|
+
function orm(opts) {
|
|
50
|
+
const t = usesTables(opts);
|
|
51
|
+
const where = (filter) => filter.replace(/\s/g, "") === "{}" ? "" : `{ where: ${filter} }`;
|
|
52
|
+
const byId = (id) => id === "id" ? "{ id }" : `{ id: ${id} }`;
|
|
53
|
+
return {
|
|
54
|
+
findOne: (m, filter) => t ? `${m}.findFirst({ where: ${filter} })` : `${m}.findOne(${filter})`,
|
|
55
|
+
findById: (m, id) => t ? `${m}.findUnique({ where: ${byId(id)} })` : `${m}.findById(${id})`,
|
|
56
|
+
find: (m, filter) => t ? `${m}.findMany(${where(filter)})` : `${m}.find(${filter})`,
|
|
57
|
+
count: (m, filter) => t ? `${m}.count(${where(filter)})` : `${m}.count(${filter})`,
|
|
58
|
+
create: (m, data) => t ? `${m}.create({ data: ${data} })` : `${m}.create(${data})`,
|
|
59
|
+
update: (m, id, data) => t ? `${m}.update({ where: ${byId(id)}, data: ${data} })` : `${m}.update(${id}, ${data})`,
|
|
60
|
+
delete: (m, id) => t ? `${m}.delete({ where: ${byId(id)} })` : `${m}.delete(${id})`
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function generatedModels(opts) {
|
|
64
|
+
const uf = opts.userFeatures;
|
|
65
|
+
const af = opts.adminFeatures;
|
|
66
|
+
const user2 = opts.userPortal;
|
|
67
|
+
const admin2 = opts.adminPortal;
|
|
68
|
+
const m = /* @__PURE__ */ new Set();
|
|
69
|
+
if (user2 || admin2 && af.userManagement) m.add("User");
|
|
70
|
+
if (admin2) m.add("Admin");
|
|
71
|
+
if (opts.registry && (user2 || admin2)) m.add("User").add("Admin");
|
|
72
|
+
if (user2 && uf.accountSecurity) m.add("Session");
|
|
73
|
+
if (user2 && uf.notifications || admin2 && af.notificationsAndLogs) m.add("Notification");
|
|
74
|
+
if (user2 && uf.filesAndMedia) m.add("File");
|
|
75
|
+
if (user2 && uf.support || admin2 && af.supportManagement) m.add("SupportTicket");
|
|
76
|
+
if (admin2 && af.notificationsAndLogs) m.add("AuditLog");
|
|
77
|
+
if (user2 && uf.apiAndBilling) m.add("Subscription").add("Invoice").add("ApiKey");
|
|
78
|
+
if (usesOAuth(opts)) m.add("OAuthAccount");
|
|
79
|
+
if (admin2 && af.billingManagement) m.add("Plan").add("Coupon");
|
|
80
|
+
if (opts.rbac && admin2 && af.adminManagement) m.add("Role");
|
|
81
|
+
if (admin2 && af.contentManagement) m.add("FAQ").add("Blog").add("Category");
|
|
82
|
+
if (opts.registry && m.has("Subscription")) m.add("Plan");
|
|
83
|
+
return m;
|
|
84
|
+
}
|
|
85
|
+
function modelApi(opts) {
|
|
86
|
+
const name = usesTables(opts) ? "defineTable" : "defineModel";
|
|
87
|
+
return { name, import: `import { ${name} } from 'express-file-cluster';` };
|
|
88
|
+
}
|
|
89
|
+
function refField(opts, r) {
|
|
90
|
+
if (!usesTables(opts) || !generatedModels(opts).has(r.target)) return r.legacy;
|
|
91
|
+
const opts2 = `${r.required ? ", required: true" : ""}${r.onDelete === "restrict" ? "" : `, onDelete: '${r.onDelete}'`}`;
|
|
92
|
+
return ` ${(r.field + ":").padEnd(13)}{ type: 'uuid', ref: '${r.target}'${opts2} },`;
|
|
93
|
+
}
|
|
94
|
+
function relationsOption(opts, rels) {
|
|
95
|
+
if (!usesTables(opts) || opts.registry) return "";
|
|
96
|
+
const models = generatedModels(opts);
|
|
97
|
+
const live = rels.filter((r) => models.has(r.target));
|
|
98
|
+
if (live.length === 0) return "";
|
|
99
|
+
const lines = live.map(
|
|
100
|
+
(r) => ` ${r.name}: { ${r.kind}: '${r.target}', foreignKey: '${r.foreignKey}' },`
|
|
101
|
+
);
|
|
102
|
+
return `, {
|
|
103
|
+
relations: {
|
|
104
|
+
${lines.join("\n")}
|
|
105
|
+
},
|
|
106
|
+
}`;
|
|
107
|
+
}
|
|
33
108
|
async function scaffold(opts) {
|
|
34
109
|
const dest = path.resolve(process.cwd(), opts.projectName);
|
|
35
110
|
await fs.ensureDir(dest);
|
|
36
111
|
const uf = opts.userFeatures;
|
|
37
|
-
const
|
|
38
|
-
const needsUserModel = opts.userPortal || opts.adminPortal && af.userManagement;
|
|
112
|
+
const models = generatedModels(opts);
|
|
39
113
|
await writePackageJson(dest, opts);
|
|
40
114
|
await writeTsConfig(dest, opts);
|
|
41
115
|
await writeEfcConfig(dest, opts);
|
|
@@ -44,28 +118,32 @@ async function scaffold(opts) {
|
|
|
44
118
|
await writeAgentsMd(dest, opts);
|
|
45
119
|
await writeEnvFiles(dest, opts);
|
|
46
120
|
await writeExampleRoute(dest, opts);
|
|
47
|
-
if (
|
|
48
|
-
if (
|
|
49
|
-
await writeAuthRoutes(dest, opts);
|
|
121
|
+
if (models.has("User")) await writeUserModel(dest, opts);
|
|
122
|
+
if (models.has("Admin")) await writeAdminModel(dest, opts);
|
|
123
|
+
if (!opts.registry || opts.userPortal || opts.adminPortal) await writeAuthRoutes(dest, opts);
|
|
50
124
|
if (opts.adminPortal) await writeAdminRoutes(dest, opts);
|
|
51
125
|
if (opts.userPortal && uf.profileViewing) await writeUserRoutes(dest, opts);
|
|
52
126
|
if (opts.tasks) await writeExampleTask(dest, opts);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
127
|
+
const extended = [
|
|
128
|
+
["Session", writeSessionModel],
|
|
129
|
+
["Notification", writeNotificationModel],
|
|
130
|
+
["File", writeFileModel],
|
|
131
|
+
["SupportTicket", writeSupportTicketModel],
|
|
132
|
+
["AuditLog", writeAuditLogModel],
|
|
133
|
+
["Subscription", writeSubscriptionModel],
|
|
134
|
+
["Plan", writePlanModel],
|
|
135
|
+
["Invoice", writeInvoiceModel],
|
|
136
|
+
["ApiKey", writeApiKeyModel],
|
|
137
|
+
["Role", writeRoleModel],
|
|
138
|
+
["FAQ", writeFAQModel],
|
|
139
|
+
["Blog", writeBlogModel],
|
|
140
|
+
["Category", writeCategoryModel],
|
|
141
|
+
["Coupon", writeCouponModel]
|
|
142
|
+
];
|
|
143
|
+
for (const [name, write] of extended) if (models.has(name)) await write(dest, opts);
|
|
144
|
+
if (models.has("OAuthAccount")) await writeOAuthAccountModel(dest, opts);
|
|
145
|
+
if (usesOAuth(opts)) await writeOAuth(dest, opts);
|
|
146
|
+
if (usesTables(opts)) await writeModelIndex(dest, opts);
|
|
69
147
|
if (opts.userPortal) await writeAuthExtendedRoutes(dest, opts);
|
|
70
148
|
if (opts.userPortal) await writeUserExtendedRoutes(dest, opts);
|
|
71
149
|
if (opts.userPortal && uf.apiAndBilling) await writeUserBillingRoutes(dest, opts);
|
|
@@ -75,7 +153,7 @@ async function scaffold(opts) {
|
|
|
75
153
|
}
|
|
76
154
|
async function writePackageJson(dest, opts) {
|
|
77
155
|
const deps = {
|
|
78
|
-
"express-file-cluster": "^0.
|
|
156
|
+
"express-file-cluster": "^0.8.0"
|
|
79
157
|
};
|
|
80
158
|
if (opts.database === "mongodb") deps["mongoose"] = "^9.7.2";
|
|
81
159
|
if (opts.database === "postgresql") {
|
|
@@ -85,7 +163,9 @@ async function writePackageJson(dest, opts) {
|
|
|
85
163
|
if (opts.tasks && opts.taskBackend === "bullmq") deps["bullmq"] = "^5.0.0";
|
|
86
164
|
if (opts.tasks && opts.taskBackend === "pg-boss") deps["pg-boss"] = "^12.0.0";
|
|
87
165
|
if (opts.mailer) deps["nodemailer"] = "^6.9.0";
|
|
88
|
-
deps["
|
|
166
|
+
if (usesOAuth(opts)) deps["jose"] = "^6.2.3";
|
|
167
|
+
const auth = !opts.registry || opts.userPortal || opts.adminPortal;
|
|
168
|
+
if (auth) deps["bcrypt"] = "^5.1.0";
|
|
89
169
|
const devDeps = {
|
|
90
170
|
vitest: "^5.0.3"
|
|
91
171
|
};
|
|
@@ -96,7 +176,7 @@ async function writePackageJson(dest, opts) {
|
|
|
96
176
|
devDeps["@types/express"] = "^4.17.21";
|
|
97
177
|
devDeps["tsx"] = "^4.0.0";
|
|
98
178
|
if (opts.mailer) devDeps["@types/nodemailer"] = "^6.4.0";
|
|
99
|
-
devDeps["@types/bcrypt"] = "^5.0.0";
|
|
179
|
+
if (auth) devDeps["@types/bcrypt"] = "^5.0.0";
|
|
100
180
|
if (opts.database === "postgresql") devDeps["@types/pg"] = "^8.11.0";
|
|
101
181
|
}
|
|
102
182
|
const pkg = {
|
|
@@ -229,11 +309,17 @@ Optional \u2014 the same docs over MCP, with search and scaffolding tools:
|
|
|
229
309
|
- \`src/api/**\` \u2014 every file is a route (\`users/[id].${ext}\` \u2192 \`/v1/api/users/:id\`); export
|
|
230
310
|
uppercase \`GET\`/\`POST\`/\u2026 handlers.
|
|
231
311
|
- \`src/tasks/*.${ext}\` \u2014 background tasks; the file name is the task name (not recursive).
|
|
232
|
-
|
|
312
|
+
${usesTables(opts) ? `- \`src/model/*.${ext}\` \u2014 \`defineTable\` models (PostgreSQL-native, Prisma-style queries:
|
|
313
|
+
\`findMany({ where, include })\`, \`create({ data })\`, \`update({ where, data })\`). A \`ref\` is a
|
|
314
|
+
foreign key; ${opts.registry ? "add back-relations to a model's `relations` when you need `include`" : "back-relations are in each model's `relations`"}. \`src/model/index.${ext}\` imports
|
|
315
|
+
them all${opts.language === "typescript" ? " and declares `Tables` (typed `include`)" : ""} \u2014 add new models there.` : `- \`src/model/*.${ext}\` \u2014 \`defineModel\` models.`}
|
|
233
316
|
- \`src/index.${ext}\` is the entry point; it imports \`efc.config.${ext}\` and passes it to \`ignite()\`.
|
|
234
317
|
- Only three import paths exist: \`express-file-cluster\`, \`express-file-cluster/auth\`,
|
|
235
318
|
\`express-file-cluster/tasks\`.
|
|
236
|
-
|
|
319
|
+
${opts.registry ? `- Ready-made features (auth, user/admin portals, billing, mailer\u2026) come from the registry:
|
|
320
|
+
\`npx efc list\`, then \`npx efc add <item> --yes\` \u2014 check it before hand-writing auth or CRUD
|
|
321
|
+
(\`${D}/cli/add.md\`). \`efc.json\` tracks what was added; commit it.
|
|
322
|
+
` : ""}
|
|
237
323
|
## Commands
|
|
238
324
|
|
|
239
325
|
\`\`\`bash
|
|
@@ -271,16 +357,48 @@ SMTP_PASS=${isGmail ? "your_16_char_app_password" : "your_smtp_password"}${passC
|
|
|
271
357
|
SMTP_FROM=noreply@yourapp.com
|
|
272
358
|
` : "";
|
|
273
359
|
const redis = opts.tasks && opts.taskBackend !== "pg-boss" ? "\nREDIS_URL=redis://localhost:6379" : "";
|
|
360
|
+
const oauth = opts.oauth ?? {};
|
|
361
|
+
const oauthVars = [
|
|
362
|
+
...oauth.google ? [
|
|
363
|
+
["GOOGLE_CLIENT_ID", "", "your-client-id.apps.googleusercontent.com"],
|
|
364
|
+
["GOOGLE_CLIENT_SECRET", "", "your-client-secret"],
|
|
365
|
+
[
|
|
366
|
+
"GOOGLE_REDIRECT_URI",
|
|
367
|
+
"http://localhost:3000/v1/api/auth/google/callback",
|
|
368
|
+
"https://api.yourapp.com/v1/api/auth/google/callback"
|
|
369
|
+
]
|
|
370
|
+
] : [],
|
|
371
|
+
...oauth.apple ? [
|
|
372
|
+
["APPLE_CLIENT_ID", "", "com.yourapp.signin"],
|
|
373
|
+
["APPLE_TEAM_ID", "", "ABCDE12345"],
|
|
374
|
+
["APPLE_KEY_ID", "", "FGHIJ67890"],
|
|
375
|
+
[
|
|
376
|
+
"APPLE_PRIVATE_KEY",
|
|
377
|
+
"",
|
|
378
|
+
"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----"
|
|
379
|
+
],
|
|
380
|
+
[
|
|
381
|
+
"APPLE_REDIRECT_URI",
|
|
382
|
+
"https://api.yourapp.com/v1/api/auth/apple/callback",
|
|
383
|
+
"https://api.yourapp.com/v1/api/auth/apple/callback"
|
|
384
|
+
]
|
|
385
|
+
] : [],
|
|
386
|
+
...usesOAuth(opts) ? [["OAUTH_SUCCESS_REDIRECT", "http://localhost:3000/", "https://yourapp.com/"]] : []
|
|
387
|
+
];
|
|
388
|
+
const oauthEnv = oauthVars.map(([k, v]) => `
|
|
389
|
+
${k}=${v}`).join("");
|
|
390
|
+
const oauthExample = oauthVars.map(([k, , ex]) => `
|
|
391
|
+
${k}=${ex}`).join("");
|
|
274
392
|
const dotenv = `PORT=3000
|
|
275
393
|
NODE_ENV=development
|
|
276
394
|
DATABASE_URL=${dbUrl}
|
|
277
395
|
JWT_SECRET=${secret}${redis}
|
|
278
|
-
CORS_ORIGINS=http://localhost:3000${smtpVars}`;
|
|
396
|
+
CORS_ORIGINS=http://localhost:3000${smtpVars}${oauthEnv}`;
|
|
279
397
|
const example = `PORT=3000
|
|
280
398
|
NODE_ENV=development
|
|
281
399
|
DATABASE_URL=${dbExampleUrl}
|
|
282
400
|
JWT_SECRET=<generate with: openssl rand -hex 64>${redis}
|
|
283
|
-
CORS_ORIGINS=http://localhost:3000,https://yourapp.com${smtpExample}`;
|
|
401
|
+
CORS_ORIGINS=http://localhost:3000,https://yourapp.com${smtpExample}${oauthExample}`;
|
|
284
402
|
await fs.outputFile(path.join(dest, ".env"), dotenv);
|
|
285
403
|
await fs.outputFile(path.join(dest, ".env.example"), example);
|
|
286
404
|
}
|
|
@@ -391,9 +509,9 @@ export default defineTask(async (payload) => {
|
|
|
391
509
|
}
|
|
392
510
|
async function writeUserModel(dest, opts) {
|
|
393
511
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
394
|
-
const content =
|
|
512
|
+
const content = `${modelApi(opts).import}
|
|
395
513
|
|
|
396
|
-
export const User =
|
|
514
|
+
export const User = ${modelApi(opts).name}('User', {
|
|
397
515
|
name: { type: 'string', required: true },
|
|
398
516
|
email: { type: 'string', required: true, unique: true },
|
|
399
517
|
password: { type: 'string', required: true },
|
|
@@ -406,15 +524,23 @@ export const User = defineModel('User', {
|
|
|
406
524
|
resetTokenExpiry: { type: 'date' },
|
|
407
525
|
refreshToken: { type: 'string' },
|
|
408
526
|
refreshTokenExpiry: { type: 'date' },
|
|
409
|
-
}
|
|
527
|
+
}${relationsOption(opts, [
|
|
528
|
+
{ name: "sessions", kind: "hasMany", target: "Session", foreignKey: "user" },
|
|
529
|
+
{ name: "notifications", kind: "hasMany", target: "Notification", foreignKey: "user" },
|
|
530
|
+
{ name: "files", kind: "hasMany", target: "File", foreignKey: "user" },
|
|
531
|
+
{ name: "tickets", kind: "hasMany", target: "SupportTicket", foreignKey: "user" },
|
|
532
|
+
{ name: "subscriptions", kind: "hasMany", target: "Subscription", foreignKey: "user" },
|
|
533
|
+
{ name: "invoices", kind: "hasMany", target: "Invoice", foreignKey: "user" },
|
|
534
|
+
{ name: "apiKeys", kind: "hasMany", target: "ApiKey", foreignKey: "user" }
|
|
535
|
+
])});
|
|
410
536
|
`;
|
|
411
537
|
await fs.outputFile(path.join(dest, "src", "model", `User.${ext}`), content);
|
|
412
538
|
}
|
|
413
539
|
async function writeAdminModel(dest, opts) {
|
|
414
540
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
415
|
-
const content =
|
|
541
|
+
const content = `${modelApi(opts).import}
|
|
416
542
|
|
|
417
|
-
export const Admin =
|
|
543
|
+
export const Admin = ${modelApi(opts).name}('Admin', {
|
|
418
544
|
name: { type: 'string', required: true },
|
|
419
545
|
email: { type: 'string', required: true, unique: true },
|
|
420
546
|
password: { type: 'string', required: true },
|
|
@@ -425,11 +551,16 @@ export const Admin = defineModel('Admin', {
|
|
|
425
551
|
resetTokenExpiry: { type: 'date' },
|
|
426
552
|
refreshToken: { type: 'string' },
|
|
427
553
|
refreshTokenExpiry: { type: 'date' },
|
|
428
|
-
}
|
|
554
|
+
}${relationsOption(opts, [
|
|
555
|
+
{ name: "assignedTickets", kind: "hasMany", target: "SupportTicket", foreignKey: "assignedTo" },
|
|
556
|
+
{ name: "auditLogs", kind: "hasMany", target: "AuditLog", foreignKey: "admin" },
|
|
557
|
+
{ name: "posts", kind: "hasMany", target: "Blog", foreignKey: "author" }
|
|
558
|
+
])});
|
|
429
559
|
`;
|
|
430
560
|
await fs.outputFile(path.join(dest, "src", "model", `Admin.${ext}`), content);
|
|
431
561
|
}
|
|
432
562
|
async function writeAuthRoutes(dest, opts) {
|
|
563
|
+
const db = orm(opts);
|
|
433
564
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
434
565
|
const ts = opts.language === "typescript";
|
|
435
566
|
const loginMeta = opts.routeDocs ? ts ? `import type { RouteMeta } from 'express-file-cluster';
|
|
@@ -451,15 +582,14 @@ export const meta: RouteMeta = {
|
|
|
451
582
|
};
|
|
452
583
|
|
|
453
584
|
` : "";
|
|
454
|
-
const hasUser = opts
|
|
455
|
-
const hasAdmin = opts.adminPortal;
|
|
585
|
+
const { user: hasUser, admin: hasAdmin } = principals(opts);
|
|
456
586
|
const hasModels = hasUser || hasAdmin;
|
|
457
587
|
const loginDbImports = hasModels ? `import bcrypt from 'bcrypt';
|
|
458
588
|
import crypto from 'node:crypto';
|
|
459
589
|
${hasUser ? `import { User } from '../../model/User.js';
|
|
460
590
|
` : ""}${hasAdmin ? `import { Admin } from '../../model/Admin.js';
|
|
461
591
|
` : ""}` : "";
|
|
462
|
-
const loginBody = hasAdmin && hasUser ? ` const admin = await
|
|
592
|
+
const loginBody = hasAdmin && hasUser ? ` const admin = await ${db.findOne("Admin", `{ email }`)};
|
|
463
593
|
if (admin) {
|
|
464
594
|
const match = await bcrypt.compare(password, admin.password);
|
|
465
595
|
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
|
@@ -469,21 +599,21 @@ ${hasUser ? `import { User } from '../../model/User.js';
|
|
|
469
599
|
return res.json({ message: 'Logged in as admin' });
|
|
470
600
|
}
|
|
471
601
|
|
|
472
|
-
const user = await
|
|
602
|
+
const user = await ${db.findOne("User", `{ email }`)};
|
|
473
603
|
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
|
474
604
|
const match = await bcrypt.compare(password, user.password);
|
|
475
605
|
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
|
476
606
|
if (!user.isActive) return res.status(403).json({ error: 'Account suspended' });
|
|
477
607
|
await issueToken(res, { id: user.id, role: user.role, email: user.email });
|
|
478
608
|
await issueRefreshToken(res, User, user.id);
|
|
479
|
-
res.json({ message: 'Logged in' });` : hasAdmin ? ` const admin = await
|
|
609
|
+
res.json({ message: 'Logged in' });` : hasAdmin ? ` const admin = await ${db.findOne("Admin", `{ email }`)};
|
|
480
610
|
if (!admin) return res.status(401).json({ error: 'Invalid credentials' });
|
|
481
611
|
const match = await bcrypt.compare(password, admin.password);
|
|
482
612
|
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
|
483
613
|
if (!admin.isActive) return res.status(403).json({ error: 'Account suspended' });
|
|
484
614
|
await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
|
|
485
615
|
await issueRefreshToken(res, Admin, admin.id);
|
|
486
|
-
res.json({ message: 'Logged in as admin' });` : ` const user = await
|
|
616
|
+
res.json({ message: 'Logged in as admin' });` : ` const user = await ${db.findOne("User", `{ email }`)};
|
|
487
617
|
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
|
488
618
|
const match = await bcrypt.compare(password, user.password);
|
|
489
619
|
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
|
@@ -497,11 +627,11 @@ ${loginDbImports}${loginMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 *
|
|
|
497
627
|
|
|
498
628
|
async function issueRefreshToken(
|
|
499
629
|
res: Response,
|
|
500
|
-
model: { update: (id: string, data: Record<string, unknown>) => Promise<unknown> },
|
|
630
|
+
model: ${usesTables(opts) ? "{ update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown> }" : "{ update: (id: string, data: Record<string, unknown>) => Promise<unknown> }"},
|
|
501
631
|
id: string,
|
|
502
632
|
): Promise<void> {
|
|
503
633
|
const refreshToken = crypto.randomBytes(40).toString('hex');
|
|
504
|
-
await
|
|
634
|
+
await ${db.update("model", "id", "{ refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) }")};
|
|
505
635
|
res.cookie('efc_refresh_token', refreshToken, {
|
|
506
636
|
httpOnly: true,
|
|
507
637
|
secure: process.env.NODE_ENV === 'production',
|
|
@@ -521,7 +651,7 @@ ${loginDbImports}${loginMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 *
|
|
|
521
651
|
|
|
522
652
|
async function issueRefreshToken(res, model, id) {
|
|
523
653
|
const refreshToken = crypto.randomBytes(40).toString('hex');
|
|
524
|
-
await
|
|
654
|
+
await ${db.update("model", "id", "{ refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) }")};
|
|
525
655
|
res.cookie('efc_refresh_token', refreshToken, {
|
|
526
656
|
httpOnly: true,
|
|
527
657
|
secure: process.env.NODE_ENV === 'production',
|
|
@@ -585,7 +715,7 @@ ${logoutMeta}export const POST = async (req, res) => {
|
|
|
585
715
|
`;
|
|
586
716
|
await fs.outputFile(path.join(dest, "src", "api", "auth", `login.${ext}`), loginContent);
|
|
587
717
|
await fs.outputFile(path.join(dest, "src", "api", "auth", `logout.${ext}`), logoutContent);
|
|
588
|
-
if (!
|
|
718
|
+
if (!hasUser) return;
|
|
589
719
|
const registerMeta = opts.routeDocs ? ts ? `import type { RouteMeta } from 'express-file-cluster';
|
|
590
720
|
|
|
591
721
|
export const meta: RouteMeta = {
|
|
@@ -607,9 +737,9 @@ export const meta: RouteMeta = {
|
|
|
607
737
|
` : "";
|
|
608
738
|
const registerDbImports = `import bcrypt from 'bcrypt';
|
|
609
739
|
import crypto from 'node:crypto';
|
|
610
|
-
${opts
|
|
740
|
+
${sendsMail(opts) ? "import { enqueue } from 'express-file-cluster/tasks';\n" : ""}import { User } from '../../model/User.js';
|
|
611
741
|
`;
|
|
612
|
-
const sendVerifyEmail = opts
|
|
742
|
+
const sendVerifyEmail = sendsMail(opts) ? `
|
|
613
743
|
var appUrl = process.env.APP_URL || 'http://localhost:3000';
|
|
614
744
|
await enqueue('SendEmail', {
|
|
615
745
|
to: email,
|
|
@@ -625,11 +755,11 @@ ${registerDbImports}${registerMeta}export const POST = async (req: Request, res:
|
|
|
625
755
|
if (!name || !email || !password) {
|
|
626
756
|
return res.status(400).json({ error: 'name, email and password are required' });
|
|
627
757
|
}
|
|
628
|
-
const existing = await
|
|
758
|
+
const existing = await ${db.findOne("User", `{ email }`)};
|
|
629
759
|
if (existing) return res.status(409).json({ error: 'Email already in use' });
|
|
630
760
|
const hashed = await bcrypt.hash(password, 10);
|
|
631
761
|
const verifyToken = crypto.randomBytes(32).toString('hex');
|
|
632
|
-
const user = await
|
|
762
|
+
const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
|
|
633
763
|
${sendVerifyEmail}
|
|
634
764
|
const { password: _, verifyToken: __, ...safe } = user;
|
|
635
765
|
res.status(201).json({ message: 'Account created successfully', user: safe });
|
|
@@ -639,11 +769,11 @@ ${sendVerifyEmail}
|
|
|
639
769
|
if (!name || !email || !password) {
|
|
640
770
|
return res.status(400).json({ error: 'name, email and password are required' });
|
|
641
771
|
}
|
|
642
|
-
const existing = await
|
|
772
|
+
const existing = await ${db.findOne("User", `{ email }`)};
|
|
643
773
|
if (existing) return res.status(409).json({ error: 'Email already in use' });
|
|
644
774
|
const hashed = await bcrypt.hash(password, 10);
|
|
645
775
|
const verifyToken = crypto.randomBytes(32).toString('hex');
|
|
646
|
-
const user = await
|
|
776
|
+
const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
|
|
647
777
|
${sendVerifyEmail}
|
|
648
778
|
const { password: _, verifyToken: __, ...safe } = user;
|
|
649
779
|
res.status(201).json({ message: 'Account created successfully', user: safe });
|
|
@@ -685,6 +815,7 @@ ${meMeta}${meMiddlewares}export const GET = async (req, res) => {
|
|
|
685
815
|
await fs.outputFile(path.join(dest, "src", "api", "auth", `me.${ext}`), meContent);
|
|
686
816
|
}
|
|
687
817
|
async function writeAdminRoutes(dest, opts) {
|
|
818
|
+
const db = orm(opts);
|
|
688
819
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
689
820
|
const ts = opts.language === "typescript";
|
|
690
821
|
const middlewares = opts.rbac ? `export const middlewares = [requireAuth('admin')];
|
|
@@ -718,7 +849,7 @@ export const meta: RouteMeta = {
|
|
|
718
849
|
};
|
|
719
850
|
|
|
720
851
|
` : "";
|
|
721
|
-
const dashboardHasUserModel = opts.adminFeatures.userManagement;
|
|
852
|
+
const dashboardHasUserModel = !!opts.registry || opts.adminFeatures.userManagement;
|
|
722
853
|
const dashboardDbImport = dashboardHasUserModel ? `import { User } from '../../model/User.js';
|
|
723
854
|
` : "";
|
|
724
855
|
const dashboardContent = dashboardHasUserModel ? ts ? `import { requireAuth } from 'express-file-cluster/auth';
|
|
@@ -726,9 +857,9 @@ import type { Request, Response } from 'express';
|
|
|
726
857
|
${dashboardDbImport}${dashboardMeta}${middlewares}
|
|
727
858
|
export const GET = async (_req: Request, res: Response) => {
|
|
728
859
|
${roleGuard} const [totalUsers, activeUsers, verifiedUsers] = await Promise.all([
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
860
|
+
${db.count("User", `{}`)},
|
|
861
|
+
${db.count("User", `{ isActive: true }`)},
|
|
862
|
+
${db.count("User", `{ isVerified: true }`)},
|
|
732
863
|
]);
|
|
733
864
|
res.json({ stats: { totalUsers, activeUsers, verifiedUsers } });
|
|
734
865
|
};
|
|
@@ -736,9 +867,9 @@ ${roleGuard} const [totalUsers, activeUsers, verifiedUsers] = await Promise.all
|
|
|
736
867
|
${dashboardDbImport}${dashboardMeta}${middlewares}
|
|
737
868
|
export const GET = async (_req, res) => {
|
|
738
869
|
${roleGuard} const [totalUsers, activeUsers, verifiedUsers] = await Promise.all([
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
870
|
+
${db.count("User", `{}`)},
|
|
871
|
+
${db.count("User", `{ isActive: true }`)},
|
|
872
|
+
${db.count("User", `{ isVerified: true }`)},
|
|
742
873
|
]);
|
|
743
874
|
res.json({ stats: { totalUsers, activeUsers, verifiedUsers } });
|
|
744
875
|
};
|
|
@@ -798,7 +929,7 @@ ${adminUsersDbImport}${usersListMeta}${middlewares}
|
|
|
798
929
|
export const GET = async (req: Request, res: Response) => {
|
|
799
930
|
${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
|
|
800
931
|
const limit = Math.min(100, Number(req.query.limit) || 20);
|
|
801
|
-
const [all, total] = await Promise.all([
|
|
932
|
+
const [all, total] = await Promise.all([${db.find("User", `{}`)}, ${db.count("User", `{}`)}]);
|
|
802
933
|
const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, ...u }) => u);
|
|
803
934
|
res.json({ users, total, page, limit });
|
|
804
935
|
};
|
|
@@ -806,10 +937,10 @@ ${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
|
|
|
806
937
|
export const POST = async (req: Request, res: Response) => {
|
|
807
938
|
${roleGuard} const { name, email, password, role } = req.body;
|
|
808
939
|
if (!name || !email || !password) return res.status(400).json({ error: 'name, email and password are required' });
|
|
809
|
-
const existing = await
|
|
940
|
+
const existing = await ${db.findOne("User", `{ email }`)};
|
|
810
941
|
if (existing) return res.status(409).json({ error: 'Email already in use' });
|
|
811
942
|
const hashed = await bcrypt.hash(password, 10);
|
|
812
|
-
const user = await
|
|
943
|
+
const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
|
|
813
944
|
const { password: _, ...safe } = user;
|
|
814
945
|
res.status(201).json({ message: 'User created', user: safe });
|
|
815
946
|
};
|
|
@@ -818,7 +949,7 @@ ${adminUsersDbImport}${usersListMeta}${middlewares}
|
|
|
818
949
|
export const GET = async (req, res) => {
|
|
819
950
|
${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
|
|
820
951
|
const limit = Math.min(100, Number(req.query.limit) || 20);
|
|
821
|
-
const [all, total] = await Promise.all([
|
|
952
|
+
const [all, total] = await Promise.all([${db.find("User", `{}`)}, ${db.count("User", `{}`)}]);
|
|
822
953
|
const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, ...u }) => u);
|
|
823
954
|
res.json({ users, total, page, limit });
|
|
824
955
|
};
|
|
@@ -826,10 +957,10 @@ ${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
|
|
|
826
957
|
export const POST = async (req, res) => {
|
|
827
958
|
${roleGuard} const { name, email, password, role } = req.body;
|
|
828
959
|
if (!name || !email || !password) return res.status(400).json({ error: 'name, email and password are required' });
|
|
829
|
-
const existing = await
|
|
960
|
+
const existing = await ${db.findOne("User", `{ email }`)};
|
|
830
961
|
if (existing) return res.status(409).json({ error: 'Email already in use' });
|
|
831
962
|
const hashed = await bcrypt.hash(password, 10);
|
|
832
|
-
const user = await
|
|
963
|
+
const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
|
|
833
964
|
const { password: _, ...safe } = user;
|
|
834
965
|
res.status(201).json({ message: 'User created', user: safe });
|
|
835
966
|
};
|
|
@@ -884,7 +1015,7 @@ import type { Request, Response } from 'express';
|
|
|
884
1015
|
${adminUserByIdDbImport}${userByIdMeta}${middlewares}
|
|
885
1016
|
export const GET = async (req: Request, res: Response) => {
|
|
886
1017
|
${roleGuard} const { id } = req.params;
|
|
887
|
-
const user = await
|
|
1018
|
+
const user = await ${db.findById("User", `id`)};
|
|
888
1019
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
889
1020
|
const { password: _, ...safe } = user;
|
|
890
1021
|
res.json({ user: safe });
|
|
@@ -893,7 +1024,7 @@ ${roleGuard} const { id } = req.params;
|
|
|
893
1024
|
export const PUT = async (req: Request, res: Response) => {
|
|
894
1025
|
${roleGuard} const { id } = req.params;
|
|
895
1026
|
const { name, email, role, isActive } = req.body;
|
|
896
|
-
const updated = await
|
|
1027
|
+
const updated = await ${db.update("User", `id`, `{ name, email, role, isActive }`)};
|
|
897
1028
|
if (!updated) return res.status(404).json({ error: 'User not found' });
|
|
898
1029
|
const { password: _, ...safe } = updated;
|
|
899
1030
|
res.json({ message: 'User updated', user: safe });
|
|
@@ -901,16 +1032,16 @@ ${roleGuard} const { id } = req.params;
|
|
|
901
1032
|
|
|
902
1033
|
export const DELETE = async (req: Request, res: Response) => {
|
|
903
1034
|
${roleGuard} const { id } = req.params;
|
|
904
|
-
const user = await
|
|
1035
|
+
const user = await ${db.findById("User", `id`)};
|
|
905
1036
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
906
|
-
await
|
|
1037
|
+
await ${db.delete("User", `id`)};
|
|
907
1038
|
res.json({ message: \`User \${id} deleted\` });
|
|
908
1039
|
};
|
|
909
1040
|
` : `import { requireAuth } from 'express-file-cluster/auth';
|
|
910
1041
|
${adminUserByIdDbImport}${userByIdMeta}${middlewares}
|
|
911
1042
|
export const GET = async (req, res) => {
|
|
912
1043
|
${roleGuard} const { id } = req.params;
|
|
913
|
-
const user = await
|
|
1044
|
+
const user = await ${db.findById("User", `id`)};
|
|
914
1045
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
915
1046
|
const { password: _, ...safe } = user;
|
|
916
1047
|
res.json({ user: safe });
|
|
@@ -919,7 +1050,7 @@ ${roleGuard} const { id } = req.params;
|
|
|
919
1050
|
export const PUT = async (req, res) => {
|
|
920
1051
|
${roleGuard} const { id } = req.params;
|
|
921
1052
|
const { name, email, role, isActive } = req.body;
|
|
922
|
-
const updated = await
|
|
1053
|
+
const updated = await ${db.update("User", `id`, `{ name, email, role, isActive }`)};
|
|
923
1054
|
if (!updated) return res.status(404).json({ error: 'User not found' });
|
|
924
1055
|
const { password: _, ...safe } = updated;
|
|
925
1056
|
res.json({ message: 'User updated', user: safe });
|
|
@@ -927,9 +1058,9 @@ ${roleGuard} const { id } = req.params;
|
|
|
927
1058
|
|
|
928
1059
|
export const DELETE = async (req, res) => {
|
|
929
1060
|
${roleGuard} const { id } = req.params;
|
|
930
|
-
const user = await
|
|
1061
|
+
const user = await ${db.findById("User", `id`)};
|
|
931
1062
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
932
|
-
await
|
|
1063
|
+
await ${db.delete("User", `id`)};
|
|
933
1064
|
res.json({ message: \`User \${id} deleted\` });
|
|
934
1065
|
};
|
|
935
1066
|
`;
|
|
@@ -939,6 +1070,7 @@ ${roleGuard} const { id } = req.params;
|
|
|
939
1070
|
);
|
|
940
1071
|
}
|
|
941
1072
|
async function writeUserRoutes(dest, opts) {
|
|
1073
|
+
const db = orm(opts);
|
|
942
1074
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
943
1075
|
const ts = opts.language === "typescript";
|
|
944
1076
|
const middlewares = opts.rbac ? `export const middlewares = [requireAuth('user', 'admin')];
|
|
@@ -978,7 +1110,7 @@ import type { Request, Response } from 'express';
|
|
|
978
1110
|
${profileDbImport}${profileMeta}${middlewares}
|
|
979
1111
|
export const GET = async (req: Request, res: Response) => {
|
|
980
1112
|
const { id } = (req as any).user;
|
|
981
|
-
const user = await
|
|
1113
|
+
const user = await ${db.findById("User", `id`)};
|
|
982
1114
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
983
1115
|
const { password: _, ...safe } = user;
|
|
984
1116
|
res.json({ user: safe });
|
|
@@ -987,7 +1119,7 @@ export const GET = async (req: Request, res: Response) => {
|
|
|
987
1119
|
export const PUT = async (req: Request, res: Response) => {
|
|
988
1120
|
const { id } = (req as any).user;
|
|
989
1121
|
const { name, email } = req.body;
|
|
990
|
-
const updated = await
|
|
1122
|
+
const updated = await ${db.update("User", `id`, `{ name, email }`)};
|
|
991
1123
|
if (!updated) return res.status(404).json({ error: 'User not found' });
|
|
992
1124
|
const { password: _, ...safe } = updated;
|
|
993
1125
|
res.json({ message: 'Profile updated', user: safe });
|
|
@@ -996,7 +1128,7 @@ export const PUT = async (req: Request, res: Response) => {
|
|
|
996
1128
|
${profileDbImport}${profileMeta}${middlewares}
|
|
997
1129
|
export const GET = async (req, res) => {
|
|
998
1130
|
const { id } = req.user;
|
|
999
|
-
const user = await
|
|
1131
|
+
const user = await ${db.findById("User", `id`)};
|
|
1000
1132
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1001
1133
|
const { password: _, ...safe } = user;
|
|
1002
1134
|
res.json({ user: safe });
|
|
@@ -1005,7 +1137,7 @@ export const GET = async (req, res) => {
|
|
|
1005
1137
|
export const PUT = async (req, res) => {
|
|
1006
1138
|
const { id } = req.user;
|
|
1007
1139
|
const { name, email } = req.body;
|
|
1008
|
-
const updated = await
|
|
1140
|
+
const updated = await ${db.update("User", `id`, `{ name, email }`)};
|
|
1009
1141
|
if (!updated) return res.status(404).json({ error: 'User not found' });
|
|
1010
1142
|
const { password: _, ...safe } = updated;
|
|
1011
1143
|
res.json({ message: 'Profile updated', user: safe });
|
|
@@ -1045,10 +1177,10 @@ ${blocks}
|
|
|
1045
1177
|
}
|
|
1046
1178
|
async function writeSessionModel(dest, opts) {
|
|
1047
1179
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1048
|
-
const content =
|
|
1180
|
+
const content = `${modelApi(opts).import}
|
|
1049
1181
|
|
|
1050
|
-
export const Session =
|
|
1051
|
-
userId: { type: 'string', required: true },
|
|
1182
|
+
export const Session = ${modelApi(opts).name}('Session', {
|
|
1183
|
+
${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
|
|
1052
1184
|
token: { type: 'string', required: true, unique: true },
|
|
1053
1185
|
ip: { type: 'string', required: true },
|
|
1054
1186
|
userAgent: { type: 'string', required: true },
|
|
@@ -1060,10 +1192,10 @@ export const Session = defineModel('Session', {
|
|
|
1060
1192
|
}
|
|
1061
1193
|
async function writeNotificationModel(dest, opts) {
|
|
1062
1194
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1063
|
-
const content =
|
|
1195
|
+
const content = `${modelApi(opts).import}
|
|
1064
1196
|
|
|
1065
|
-
export const Notification =
|
|
1066
|
-
userId: { type: 'string', required: true },
|
|
1197
|
+
export const Notification = ${modelApi(opts).name}('Notification', {
|
|
1198
|
+
${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
|
|
1067
1199
|
title: { type: 'string', required: true },
|
|
1068
1200
|
message: { type: 'string', required: true },
|
|
1069
1201
|
type: { type: 'string', required: true, default: 'info' },
|
|
@@ -1075,10 +1207,10 @@ export const Notification = defineModel('Notification', {
|
|
|
1075
1207
|
}
|
|
1076
1208
|
async function writeFileModel(dest, opts) {
|
|
1077
1209
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1078
|
-
const content =
|
|
1210
|
+
const content = `${modelApi(opts).import}
|
|
1079
1211
|
|
|
1080
|
-
export const File =
|
|
1081
|
-
userId: { type: 'string', required: true },
|
|
1212
|
+
export const File = ${modelApi(opts).name}('File', {
|
|
1213
|
+
${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
|
|
1082
1214
|
filename: { type: 'string', required: true },
|
|
1083
1215
|
originalName: { type: 'string', required: true },
|
|
1084
1216
|
mimeType: { type: 'string', required: true },
|
|
@@ -1091,25 +1223,25 @@ export const File = defineModel('File', {
|
|
|
1091
1223
|
}
|
|
1092
1224
|
async function writeSupportTicketModel(dest, opts) {
|
|
1093
1225
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1094
|
-
const content =
|
|
1226
|
+
const content = `${modelApi(opts).import}
|
|
1095
1227
|
|
|
1096
|
-
export const SupportTicket =
|
|
1097
|
-
userId: { type: 'string', required: true },
|
|
1228
|
+
export const SupportTicket = ${modelApi(opts).name}('SupportTicket', {
|
|
1229
|
+
${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
|
|
1098
1230
|
subject: { type: 'string', required: true },
|
|
1099
1231
|
message: { type: 'string', required: true },
|
|
1100
1232
|
status: { type: 'string', required: true, default: 'open' },
|
|
1101
1233
|
priority: { type: 'string', required: true, default: 'normal' },
|
|
1102
|
-
assignedTo: { type: 'string' }
|
|
1234
|
+
${refField(opts, { field: "assignedTo", target: "Admin", legacy: ` assignedTo: { type: 'string' },`, onDelete: "setNull" })}
|
|
1103
1235
|
});
|
|
1104
1236
|
`;
|
|
1105
1237
|
await fs.outputFile(path.join(dest, "src", "model", `SupportTicket.${ext}`), content);
|
|
1106
1238
|
}
|
|
1107
1239
|
async function writeAuditLogModel(dest, opts) {
|
|
1108
1240
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1109
|
-
const content =
|
|
1241
|
+
const content = `${modelApi(opts).import}
|
|
1110
1242
|
|
|
1111
|
-
export const AuditLog =
|
|
1112
|
-
adminId: { type: 'string', required: true }
|
|
1243
|
+
export const AuditLog = ${modelApi(opts).name}('AuditLog', {
|
|
1244
|
+
${refField(opts, { field: "admin", target: "Admin", legacy: ` adminId: { type: 'string', required: true },`, onDelete: "setNull" })}
|
|
1113
1245
|
action: { type: 'string', required: true },
|
|
1114
1246
|
entity: { type: 'string', required: true },
|
|
1115
1247
|
entityId: { type: 'string', required: true },
|
|
@@ -1121,41 +1253,41 @@ export const AuditLog = defineModel('AuditLog', {
|
|
|
1121
1253
|
}
|
|
1122
1254
|
async function writeSubscriptionModel(dest, opts) {
|
|
1123
1255
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1124
|
-
const content =
|
|
1256
|
+
const content = `${modelApi(opts).import}
|
|
1125
1257
|
|
|
1126
|
-
export const Subscription =
|
|
1127
|
-
userId: { type: 'string', required: true },
|
|
1128
|
-
planId: { type: 'string', required: true },
|
|
1258
|
+
export const Subscription = ${modelApi(opts).name}('Subscription', {
|
|
1259
|
+
${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
|
|
1260
|
+
${refField(opts, { field: "plan", target: "Plan", legacy: ` planId: { type: 'string', required: true },`, required: true, onDelete: "restrict" })}
|
|
1129
1261
|
status: { type: 'string', required: true, default: 'active' },
|
|
1130
1262
|
startDate: { type: 'date', required: true },
|
|
1131
1263
|
endDate: { type: 'date', required: true },
|
|
1132
1264
|
cancelledAt: { type: 'date' },
|
|
1133
|
-
});
|
|
1265
|
+
}${relationsOption(opts, [{ name: "invoices", kind: "hasMany", target: "Invoice", foreignKey: "subscription" }])});
|
|
1134
1266
|
`;
|
|
1135
1267
|
await fs.outputFile(path.join(dest, "src", "model", `Subscription.${ext}`), content);
|
|
1136
1268
|
}
|
|
1137
1269
|
async function writePlanModel(dest, opts) {
|
|
1138
1270
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1139
|
-
const content =
|
|
1271
|
+
const content = `${modelApi(opts).import}
|
|
1140
1272
|
|
|
1141
|
-
export const Plan =
|
|
1273
|
+
export const Plan = ${modelApi(opts).name}('Plan', {
|
|
1142
1274
|
name: { type: 'string', required: true },
|
|
1143
1275
|
description: { type: 'string', required: true },
|
|
1144
1276
|
price: { type: 'number', required: true },
|
|
1145
1277
|
interval: { type: 'string', required: true, default: 'monthly' },
|
|
1146
1278
|
features: { type: 'array', of: 'string', default: [] },
|
|
1147
1279
|
isActive: { type: 'boolean', default: true },
|
|
1148
|
-
});
|
|
1280
|
+
}${relationsOption(opts, [{ name: "subscriptions", kind: "hasMany", target: "Subscription", foreignKey: "plan" }])});
|
|
1149
1281
|
`;
|
|
1150
1282
|
await fs.outputFile(path.join(dest, "src", "model", `Plan.${ext}`), content);
|
|
1151
1283
|
}
|
|
1152
1284
|
async function writeInvoiceModel(dest, opts) {
|
|
1153
1285
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1154
|
-
const content =
|
|
1286
|
+
const content = `${modelApi(opts).import}
|
|
1155
1287
|
|
|
1156
|
-
export const Invoice =
|
|
1157
|
-
userId: { type: 'string', required: true },
|
|
1158
|
-
subscriptionId: { type: 'string', required: true }
|
|
1288
|
+
export const Invoice = ${modelApi(opts).name}('Invoice', {
|
|
1289
|
+
${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
|
|
1290
|
+
${refField(opts, { field: "subscription", target: "Subscription", legacy: ` subscriptionId: { type: 'string', required: true },`, onDelete: "setNull" })}
|
|
1159
1291
|
amount: { type: 'number', required: true },
|
|
1160
1292
|
currency: { type: 'string', required: true, default: 'USD' },
|
|
1161
1293
|
status: { type: 'string', required: true, default: 'pending' },
|
|
@@ -1166,10 +1298,10 @@ export const Invoice = defineModel('Invoice', {
|
|
|
1166
1298
|
}
|
|
1167
1299
|
async function writeApiKeyModel(dest, opts) {
|
|
1168
1300
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1169
|
-
const content =
|
|
1301
|
+
const content = `${modelApi(opts).import}
|
|
1170
1302
|
|
|
1171
|
-
export const ApiKey =
|
|
1172
|
-
userId: { type: 'string', required: true },
|
|
1303
|
+
export const ApiKey = ${modelApi(opts).name}('ApiKey', {
|
|
1304
|
+
${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
|
|
1173
1305
|
name: { type: 'string', required: true },
|
|
1174
1306
|
key: { type: 'string', required: true, unique: true },
|
|
1175
1307
|
lastUsed: { type: 'date' },
|
|
@@ -1181,9 +1313,9 @@ export const ApiKey = defineModel('ApiKey', {
|
|
|
1181
1313
|
}
|
|
1182
1314
|
async function writeRoleModel(dest, opts) {
|
|
1183
1315
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1184
|
-
const content =
|
|
1316
|
+
const content = `${modelApi(opts).import}
|
|
1185
1317
|
|
|
1186
|
-
export const Role =
|
|
1318
|
+
export const Role = ${modelApi(opts).name}('Role', {
|
|
1187
1319
|
name: { type: 'string', required: true, unique: true },
|
|
1188
1320
|
description: { type: 'string', required: true },
|
|
1189
1321
|
permissions: { type: 'array', of: 'string', default: [] },
|
|
@@ -1193,9 +1325,9 @@ export const Role = defineModel('Role', {
|
|
|
1193
1325
|
}
|
|
1194
1326
|
async function writeFAQModel(dest, opts) {
|
|
1195
1327
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1196
|
-
const content =
|
|
1328
|
+
const content = `${modelApi(opts).import}
|
|
1197
1329
|
|
|
1198
|
-
export const FAQ =
|
|
1330
|
+
export const FAQ = ${modelApi(opts).name}('FAQ', {
|
|
1199
1331
|
question: { type: 'string', required: true },
|
|
1200
1332
|
answer: { type: 'string', required: true },
|
|
1201
1333
|
category: { type: 'string', required: true, default: 'general' },
|
|
@@ -1207,13 +1339,13 @@ export const FAQ = defineModel('FAQ', {
|
|
|
1207
1339
|
}
|
|
1208
1340
|
async function writeBlogModel(dest, opts) {
|
|
1209
1341
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1210
|
-
const content =
|
|
1342
|
+
const content = `${modelApi(opts).import}
|
|
1211
1343
|
|
|
1212
|
-
export const Blog =
|
|
1344
|
+
export const Blog = ${modelApi(opts).name}('Blog', {
|
|
1213
1345
|
title: { type: 'string', required: true },
|
|
1214
1346
|
slug: { type: 'string', required: true, unique: true },
|
|
1215
1347
|
content: { type: 'string', required: true },
|
|
1216
|
-
authorId: { type: 'string', required: true }
|
|
1348
|
+
${refField(opts, { field: "author", target: "Admin", legacy: ` authorId: { type: 'string', required: true },`, onDelete: "setNull" })}
|
|
1217
1349
|
category: { type: 'string', required: true, default: 'general' },
|
|
1218
1350
|
tags: { type: 'array', of: 'string', default: [] },
|
|
1219
1351
|
status: { type: 'string', required: true, default: 'draft' },
|
|
@@ -1224,22 +1356,22 @@ export const Blog = defineModel('Blog', {
|
|
|
1224
1356
|
}
|
|
1225
1357
|
async function writeCategoryModel(dest, opts) {
|
|
1226
1358
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1227
|
-
const content =
|
|
1359
|
+
const content = `${modelApi(opts).import}
|
|
1228
1360
|
|
|
1229
|
-
export const Category =
|
|
1361
|
+
export const Category = ${modelApi(opts).name}('Category', {
|
|
1230
1362
|
name: { type: 'string', required: true },
|
|
1231
1363
|
slug: { type: 'string', required: true, unique: true },
|
|
1232
1364
|
description: { type: 'string' },
|
|
1233
|
-
parentId: { type: 'string' }
|
|
1234
|
-
});
|
|
1365
|
+
${refField(opts, { field: "parent", target: "Category", legacy: ` parentId: { type: 'string' },`, onDelete: "setNull" })}
|
|
1366
|
+
}${relationsOption(opts, [{ name: "children", kind: "hasMany", target: "Category", foreignKey: "parent" }])});
|
|
1235
1367
|
`;
|
|
1236
1368
|
await fs.outputFile(path.join(dest, "src", "model", `Category.${ext}`), content);
|
|
1237
1369
|
}
|
|
1238
1370
|
async function writeCouponModel(dest, opts) {
|
|
1239
1371
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1240
|
-
const content =
|
|
1372
|
+
const content = `${modelApi(opts).import}
|
|
1241
1373
|
|
|
1242
|
-
export const Coupon =
|
|
1374
|
+
export const Coupon = ${modelApi(opts).name}('Coupon', {
|
|
1243
1375
|
code: { type: 'string', required: true, unique: true },
|
|
1244
1376
|
type: { type: 'string', required: true, default: 'percent' },
|
|
1245
1377
|
value: { type: 'number', required: true },
|
|
@@ -1252,6 +1384,7 @@ export const Coupon = defineModel('Coupon', {
|
|
|
1252
1384
|
await fs.outputFile(path.join(dest, "src", "model", `Coupon.${ext}`), content);
|
|
1253
1385
|
}
|
|
1254
1386
|
async function writeAuthExtendedRoutes(dest, opts) {
|
|
1387
|
+
const db = orm(opts);
|
|
1255
1388
|
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
1256
1389
|
const ts = opts.language === "typescript";
|
|
1257
1390
|
const mwUser2 = opts.rbac ? `export const middlewares = [requireAuth('user', 'admin')];
|
|
@@ -1260,9 +1393,9 @@ async function writeAuthExtendedRoutes(dest, opts) {
|
|
|
1260
1393
|
const mwUser3 = mwUser2;
|
|
1261
1394
|
const RA = `import { requireAuth } from 'express-file-cluster/auth';
|
|
1262
1395
|
`;
|
|
1263
|
-
const hasAdmin = opts.
|
|
1396
|
+
const hasAdmin = principals(opts).admin;
|
|
1264
1397
|
const uf = opts.userFeatures;
|
|
1265
|
-
const sendResetEmail = opts
|
|
1398
|
+
const sendResetEmail = sendsMail(opts) ? ` var appUrl = process.env.APP_URL || 'http://localhost:3000';
|
|
1266
1399
|
await enqueue('SendEmail', {
|
|
1267
1400
|
to: email,
|
|
1268
1401
|
subject: 'Reset your password',
|
|
@@ -1270,7 +1403,7 @@ async function writeAuthExtendedRoutes(dest, opts) {
|
|
|
1270
1403
|
});
|
|
1271
1404
|
` : ` // TODO: email this token to the user \u2014 enable the Mailer feature to auto-wire SendEmail
|
|
1272
1405
|
`;
|
|
1273
|
-
const sendVerifyEmailResend = opts
|
|
1406
|
+
const sendVerifyEmailResend = sendsMail(opts) ? ` var appUrl = process.env.APP_URL || 'http://localhost:3000';
|
|
1274
1407
|
await enqueue('SendEmail', {
|
|
1275
1408
|
to: email,
|
|
1276
1409
|
subject: 'Verify your email address',
|
|
@@ -1278,7 +1411,7 @@ async function writeAuthExtendedRoutes(dest, opts) {
|
|
|
1278
1411
|
});
|
|
1279
1412
|
` : ` // TODO: email this token to the user \u2014 enable the Mailer feature to auto-wire SendEmail
|
|
1280
1413
|
`;
|
|
1281
|
-
const mailerTaskImport = opts
|
|
1414
|
+
const mailerTaskImport = sendsMail(opts) ? `import { enqueue } from 'express-file-cluster/tasks';
|
|
1282
1415
|
` : "";
|
|
1283
1416
|
if (uf.accountSecurity) {
|
|
1284
1417
|
const refreshMeta = mkMeta(opts, {
|
|
@@ -1290,12 +1423,12 @@ async function writeAuthExtendedRoutes(dest, opts) {
|
|
|
1290
1423
|
const refreshModelImports = `import { User } from '../../model/User.js';
|
|
1291
1424
|
${hasAdmin ? `import { Admin } from '../../model/Admin.js';
|
|
1292
1425
|
` : ""}`;
|
|
1293
|
-
const refreshLookup = hasAdmin ? ` const user = await
|
|
1294
|
-
const admin = user ? null : await
|
|
1295
|
-
const account = user || admin;` : ` const user = await
|
|
1426
|
+
const refreshLookup = hasAdmin ? ` const user = await ${db.findOne("User", `{ refreshToken: token }`)};
|
|
1427
|
+
const admin = user ? null : await ${db.findOne("Admin", `{ refreshToken: token }`)};
|
|
1428
|
+
const account = user || admin;` : ` const user = await ${db.findOne("User", `{ refreshToken: token }`)};
|
|
1296
1429
|
const account = user;`;
|
|
1297
|
-
const refreshPersist = hasAdmin ? ` if (user) await
|
|
1298
|
-
else if (admin) await
|
|
1430
|
+
const refreshPersist = hasAdmin ? ` if (user) await ${db.update("User", `user.id`, `{ refreshToken: newRefreshToken, refreshTokenExpiry }`)};
|
|
1431
|
+
else if (admin) await ${db.update("Admin", `admin.id`, `{ refreshToken: newRefreshToken, refreshTokenExpiry }`)};` : ` await ${db.update("User", `user.id`, `{ refreshToken: newRefreshToken, refreshTokenExpiry }`)};`;
|
|
1299
1432
|
const refreshContent = ts ? `import { issueToken } from 'express-file-cluster/auth';
|
|
1300
1433
|
import type { Request, Response } from 'express';
|
|
1301
1434
|
import crypto from 'node:crypto';
|
|
@@ -1376,10 +1509,10 @@ ${veMeta}export const GET = async (req: Request, res: Response) => {
|
|
|
1376
1509
|
const { token } = req.query;
|
|
1377
1510
|
if (!token || typeof token !== 'string') return res.status(400).json({ error: 'token is required' });
|
|
1378
1511
|
|
|
1379
|
-
const user = await
|
|
1512
|
+
const user = await ${db.findOne("User", `{ verifyToken: token }`)};
|
|
1380
1513
|
if (!user) return res.status(400).json({ error: 'Invalid or expired verification token' });
|
|
1381
1514
|
|
|
1382
|
-
await
|
|
1515
|
+
await ${db.update("User", `user.id`, `{ isVerified: true, verifyToken: '' }`)};
|
|
1383
1516
|
res.json({ message: 'Email verified' });
|
|
1384
1517
|
};
|
|
1385
1518
|
|
|
@@ -1387,10 +1520,10 @@ export const POST = async (req: Request, res: Response) => {
|
|
|
1387
1520
|
const { email } = req.body;
|
|
1388
1521
|
if (!email) return res.status(400).json({ error: 'email is required' });
|
|
1389
1522
|
|
|
1390
|
-
const user = await
|
|
1523
|
+
const user = await ${db.findOne("User", `{ email }`)};
|
|
1391
1524
|
if (user && !user.isVerified) {
|
|
1392
1525
|
const verifyToken = crypto.randomBytes(32).toString('hex');
|
|
1393
|
-
await
|
|
1526
|
+
await ${db.update("User", `user.id`, `{ verifyToken }`)};
|
|
1394
1527
|
${sendVerifyEmailResend} }
|
|
1395
1528
|
|
|
1396
1529
|
res.json({ message: 'Verification email sent' });
|
|
@@ -1401,10 +1534,10 @@ ${veMeta}export const GET = async (req, res) => {
|
|
|
1401
1534
|
const { token } = req.query;
|
|
1402
1535
|
if (!token || typeof token !== 'string') return res.status(400).json({ error: 'token is required' });
|
|
1403
1536
|
|
|
1404
|
-
const user = await
|
|
1537
|
+
const user = await ${db.findOne("User", `{ verifyToken: token }`)};
|
|
1405
1538
|
if (!user) return res.status(400).json({ error: 'Invalid or expired verification token' });
|
|
1406
1539
|
|
|
1407
|
-
await
|
|
1540
|
+
await ${db.update("User", `user.id`, `{ isVerified: true, verifyToken: '' }`)};
|
|
1408
1541
|
res.json({ message: 'Email verified' });
|
|
1409
1542
|
};
|
|
1410
1543
|
|
|
@@ -1412,10 +1545,10 @@ export const POST = async (req, res) => {
|
|
|
1412
1545
|
const { email } = req.body;
|
|
1413
1546
|
if (!email) return res.status(400).json({ error: 'email is required' });
|
|
1414
1547
|
|
|
1415
|
-
const user = await
|
|
1548
|
+
const user = await ${db.findOne("User", `{ email }`)};
|
|
1416
1549
|
if (user && !user.isVerified) {
|
|
1417
1550
|
const verifyToken = crypto.randomBytes(32).toString('hex');
|
|
1418
|
-
await
|
|
1551
|
+
await ${db.update("User", `user.id`, `{ verifyToken }`)};
|
|
1419
1552
|
${sendVerifyEmailResend} }
|
|
1420
1553
|
|
|
1421
1554
|
res.json({ message: 'Verification email sent' });
|
|
@@ -1434,11 +1567,11 @@ ${sendVerifyEmailResend} }
|
|
|
1434
1567
|
const fpModelImports = `import { User } from '../../model/User.js';
|
|
1435
1568
|
${hasAdmin ? `import { Admin } from '../../model/Admin.js';
|
|
1436
1569
|
` : ""}`;
|
|
1437
|
-
const fpLookup = hasAdmin ? ` const user = await
|
|
1438
|
-
const admin = user ? null : await
|
|
1570
|
+
const fpLookup = hasAdmin ? ` const user = await ${db.findOne("User", `{ email }`)};
|
|
1571
|
+
const admin = user ? null : await ${db.findOne("Admin", `{ email }`)};` : ` const user = await ${db.findOne("User", `{ email }`)};`;
|
|
1439
1572
|
const fpGuard = hasAdmin ? "user || admin" : "user";
|
|
1440
|
-
const fpPersist = hasAdmin ? ` if (user) await
|
|
1441
|
-
else if (admin) await
|
|
1573
|
+
const fpPersist = hasAdmin ? ` if (user) await ${db.update("User", `user.id`, `{ resetToken, resetTokenExpiry }`)};
|
|
1574
|
+
else if (admin) await ${db.update("Admin", `admin.id`, `{ resetToken, resetTokenExpiry }`)};` : ` await ${db.update("User", `user.id`, `{ resetToken, resetTokenExpiry }`)};`;
|
|
1442
1575
|
const fpContent = ts ? `import type { Request, Response } from 'express';
|
|
1443
1576
|
import crypto from 'node:crypto';
|
|
1444
1577
|
${mailerTaskImport}${fpModelImports}${fpMeta}const RESET_TOKEN_TTL_MS = 1000 * 60 * 60; // 1 hour
|
|
@@ -1490,12 +1623,12 @@ ${sendResetEmail} }
|
|
|
1490
1623
|
const rpModelImports = `import { User } from '../../model/User.js';
|
|
1491
1624
|
${hasAdmin ? `import { Admin } from '../../model/Admin.js';
|
|
1492
1625
|
` : ""}`;
|
|
1493
|
-
const rpLookup = hasAdmin ? ` const user = await
|
|
1494
|
-
const admin = user ? null : await
|
|
1495
|
-
const account = user || admin;` : ` const user = await
|
|
1626
|
+
const rpLookup = hasAdmin ? ` const user = await ${db.findOne("User", `{ resetToken: token }`)};
|
|
1627
|
+
const admin = user ? null : await ${db.findOne("Admin", `{ resetToken: token }`)};
|
|
1628
|
+
const account = user || admin;` : ` const user = await ${db.findOne("User", `{ resetToken: token }`)};
|
|
1496
1629
|
const account = user;`;
|
|
1497
|
-
const rpPersist = hasAdmin ? ` if (user) await
|
|
1498
|
-
else if (admin) await
|
|
1630
|
+
const rpPersist = hasAdmin ? ` if (user) await ${db.update("User", `user.id`, `{ password: hashed, resetToken: '' }`)};
|
|
1631
|
+
else if (admin) await ${db.update("Admin", `admin.id`, `{ password: hashed, resetToken: '' }`)};` : ` await ${db.update("User", `user.id`, `{ password: hashed, resetToken: '' }`)};`;
|
|
1499
1632
|
const rpContent = ts ? `import type { Request, Response } from 'express';
|
|
1500
1633
|
import bcrypt from 'bcrypt';
|
|
1501
1634
|
import { revokeAllSessions } from 'express-file-cluster/auth';
|
|
@@ -1716,7 +1849,7 @@ async function writeUserExtendedRoutes(dest, opts) {
|
|
|
1716
1849
|
const mw3 = mw2;
|
|
1717
1850
|
const RA = `import { requireAuth } from 'express-file-cluster/auth';
|
|
1718
1851
|
`;
|
|
1719
|
-
const
|
|
1852
|
+
const user2 = ts ? `(req as any).user` : `req.user`;
|
|
1720
1853
|
const uf = opts.userFeatures;
|
|
1721
1854
|
if (uf.accountSettings) {
|
|
1722
1855
|
const avatarMeta = mkMeta(opts, {
|
|
@@ -1803,7 +1936,7 @@ export const PUT = async (req, res) => {
|
|
|
1803
1936
|
${accountMeta}${mw2}
|
|
1804
1937
|
export const GET = async (req: Request, res: Response) => {
|
|
1805
1938
|
// TODO: compile and return personal data export
|
|
1806
|
-
res.json({ data: { user: ${
|
|
1939
|
+
res.json({ data: { user: ${user2}, exportedAt: new Date().toISOString() } });
|
|
1807
1940
|
};
|
|
1808
1941
|
|
|
1809
1942
|
export const DELETE = async (req: Request, res: Response) => {
|
|
@@ -1815,7 +1948,7 @@ export const DELETE = async (req: Request, res: Response) => {
|
|
|
1815
1948
|
` : `${RA}${accountMeta}${mw2}
|
|
1816
1949
|
export const GET = async (req, res) => {
|
|
1817
1950
|
// TODO: compile and return personal data export
|
|
1818
|
-
res.json({ data: { user: ${
|
|
1951
|
+
res.json({ data: { user: ${user2}, exportedAt: new Date().toISOString() } });
|
|
1819
1952
|
};
|
|
1820
1953
|
|
|
1821
1954
|
export const DELETE = async (req, res) => {
|
|
@@ -2204,7 +2337,10 @@ export const GET = async (req, res) => {
|
|
|
2204
2337
|
}
|
|
2205
2338
|
if (uf.apiAndBilling) {
|
|
2206
2339
|
const akListMeta = mkMeta(opts, {
|
|
2207
|
-
GET: {
|
|
2340
|
+
GET: {
|
|
2341
|
+
description: "List API keys for the authenticated user.",
|
|
2342
|
+
response: `{ apiKeys: [] }`
|
|
2343
|
+
},
|
|
2208
2344
|
POST: {
|
|
2209
2345
|
description: "Generate a new API key for the authenticated user.",
|
|
2210
2346
|
request: `{ name: 'CI key' }`,
|
|
@@ -3447,12 +3583,54 @@ ${roleGuard} const { status, page = 1, limit = 20 } = req.query;
|
|
|
3447
3583
|
);
|
|
3448
3584
|
}
|
|
3449
3585
|
}
|
|
3586
|
+
async function writeModelIndex(dest, opts) {
|
|
3587
|
+
const ts = opts.language === "typescript";
|
|
3588
|
+
const ext = ts ? "ts" : "js";
|
|
3589
|
+
const names = [...generatedModels(opts)].sort();
|
|
3590
|
+
if (names.length === 0) return;
|
|
3591
|
+
const exports = names.map((n) => `export { ${n} } from './${n}.js';`).join("\n");
|
|
3592
|
+
const tables = ts ? `
|
|
3593
|
+
${names.map((n) => `import type { ${n} } from './${n}.js';`).join("\n")}
|
|
3594
|
+
|
|
3595
|
+
// Types \`include\` results: relations name models by string, this maps the names to models.
|
|
3596
|
+
declare module 'express-file-cluster' {
|
|
3597
|
+
interface Tables {
|
|
3598
|
+
${names.map((n) => ` ${n}: typeof ${n};`).join("\n")}
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
` : "";
|
|
3602
|
+
const content = `// Every model, in one import: \`import { User } from '../model/index.js'\` also registers the
|
|
3603
|
+
// models its relations reach, which \`include\` needs.
|
|
3604
|
+
${exports}
|
|
3605
|
+
${tables}`;
|
|
3606
|
+
await fs.outputFile(path.join(dest, "src", "model", `index.${ext}`), content);
|
|
3607
|
+
}
|
|
3608
|
+
var MODEL_ORDER = [
|
|
3609
|
+
"User",
|
|
3610
|
+
"Admin",
|
|
3611
|
+
"Plan",
|
|
3612
|
+
"Category",
|
|
3613
|
+
"Role",
|
|
3614
|
+
"FAQ",
|
|
3615
|
+
"Coupon",
|
|
3616
|
+
"OAuthAccount",
|
|
3617
|
+
"Session",
|
|
3618
|
+
"Notification",
|
|
3619
|
+
"File",
|
|
3620
|
+
"SupportTicket",
|
|
3621
|
+
"AuditLog",
|
|
3622
|
+
"Subscription",
|
|
3623
|
+
"Invoice",
|
|
3624
|
+
"ApiKey",
|
|
3625
|
+
"Blog"
|
|
3626
|
+
];
|
|
3450
3627
|
async function writeInitMigration(dest, opts) {
|
|
3451
3628
|
const modelDir = path.join(dest, "src", "model");
|
|
3452
3629
|
if (!await fs.pathExists(modelDir)) return;
|
|
3453
3630
|
const ts = opts.language === "typescript";
|
|
3454
3631
|
const ext = ts ? "ts" : "js";
|
|
3455
|
-
const
|
|
3632
|
+
const rank = (n) => MODEL_ORDER.includes(n) ? MODEL_ORDER.indexOf(n) : MODEL_ORDER.length;
|
|
3633
|
+
const names = (await fs.readdir(modelDir)).filter((f) => f.endsWith(`.${ext}`) && f !== `index.${ext}`).map((f) => f.slice(0, -(ext.length + 1))).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
3456
3634
|
if (names.length === 0) return;
|
|
3457
3635
|
const imports = names.map((n) => `import { ${n} } from '../model/${n}.js';`).join("\n");
|
|
3458
3636
|
const typeImport = ts ? "import type { MigrationContext } from 'express-file-cluster';\n" : "";
|
|
@@ -3471,6 +3649,845 @@ export async function down${sig} {
|
|
|
3471
3649
|
`;
|
|
3472
3650
|
await fs.outputFile(path.join(dest, "src", "migrations", `00000000000000_init.${ext}`), content);
|
|
3473
3651
|
}
|
|
3652
|
+
async function writeOAuthAccountModel(dest, opts) {
|
|
3653
|
+
const ext = opts.language === "typescript" ? "ts" : "js";
|
|
3654
|
+
const content = `${modelApi(opts).import}
|
|
3655
|
+
|
|
3656
|
+
// One row per provider identity ("google:<sub>"), linked to the User it signs in as.
|
|
3657
|
+
export const OAuthAccount = ${modelApi(opts).name}('OAuthAccount', {
|
|
3658
|
+
key: { type: 'string', required: true, unique: true },
|
|
3659
|
+
provider: { type: 'string', required: true, enum: ['google', 'apple'] },
|
|
3660
|
+
providerId: { type: 'string', required: true },
|
|
3661
|
+
email: { type: 'string' },
|
|
3662
|
+
${refField(opts, { field: "user", target: "User", legacy: ` userId: { type: 'string', required: true },`, required: true, onDelete: "cascade" })}
|
|
3663
|
+
});
|
|
3664
|
+
`;
|
|
3665
|
+
await fs.outputFile(path.join(dest, "src", "model", `OAuthAccount.${ext}`), content);
|
|
3666
|
+
}
|
|
3667
|
+
async function writeOAuth(dest, opts) {
|
|
3668
|
+
const ts = opts.language === "typescript";
|
|
3669
|
+
const ext = ts ? "ts" : "js";
|
|
3670
|
+
const db = orm(opts);
|
|
3671
|
+
const T = (s) => ts ? s : "";
|
|
3672
|
+
const userField = usesTables(opts) && generatedModels(opts).has("User") ? "user" : "userId";
|
|
3673
|
+
const lib = `import crypto from 'node:crypto';
|
|
3674
|
+
import bcrypt from 'bcrypt';
|
|
3675
|
+
import { HttpError } from 'express-file-cluster';
|
|
3676
|
+
import { issueToken } from 'express-file-cluster/auth';
|
|
3677
|
+
${T("import type { Request, Response } from 'express';\n")}import { User } from '../model/User.js';
|
|
3678
|
+
import { OAuthAccount } from '../model/OAuthAccount.js';
|
|
3679
|
+
|
|
3680
|
+
// Shared by the OAuth sign-in routes (src/api/auth/<provider>/).
|
|
3681
|
+
|
|
3682
|
+
const STATE_TTL_MS = 10 * 60 * 1000; // a sign-in attempt has 10 minutes to come back
|
|
3683
|
+
${T(`
|
|
3684
|
+
export interface OAuthState {
|
|
3685
|
+
state: string;
|
|
3686
|
+
nonce: string;
|
|
3687
|
+
verifier?: string;
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
export interface OAuthProfile {
|
|
3691
|
+
provider: 'google' | 'apple';
|
|
3692
|
+
providerId: string;
|
|
3693
|
+
email?: string;
|
|
3694
|
+
emailVerified: boolean;
|
|
3695
|
+
name?: string;
|
|
3696
|
+
}
|
|
3697
|
+
`)}
|
|
3698
|
+
/** A random URL-safe string (state, nonce, PKCE verifier). */
|
|
3699
|
+
export function randomToken()${T(": string")} {
|
|
3700
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
/** The PKCE S256 challenge for a verifier. */
|
|
3704
|
+
export function pkceChallenge(verifier${T(": string")})${T(": string")} {
|
|
3705
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
/**
|
|
3709
|
+
* Remembers a sign-in attempt in a short-lived cookie until the provider redirects back.
|
|
3710
|
+
* \`crossSite\`: the provider POSTs the callback from its own site (Apple), so the cookie must
|
|
3711
|
+
* be SameSite=None \u2014 which browsers only accept with Secure (https, or localhost).
|
|
3712
|
+
*/
|
|
3713
|
+
export function rememberState(
|
|
3714
|
+
res${T(": Response")},
|
|
3715
|
+
provider${T(": string")},
|
|
3716
|
+
data${T(": OAuthState")},
|
|
3717
|
+
crossSite = false,
|
|
3718
|
+
)${T(": void")} {
|
|
3719
|
+
res.cookie(\`efc_oauth_\${provider}\`, JSON.stringify(data), {
|
|
3720
|
+
httpOnly: true,
|
|
3721
|
+
secure: crossSite || process.env.NODE_ENV === 'production',
|
|
3722
|
+
sameSite: crossSite ? 'none' : 'lax',
|
|
3723
|
+
maxAge: STATE_TTL_MS,
|
|
3724
|
+
path: '/',
|
|
3725
|
+
});
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
/** The remembered attempt (cleared) \u2014 or undefined if \`state\` doesn't match it. */
|
|
3729
|
+
export function takeState(
|
|
3730
|
+
req${T(": Request")},
|
|
3731
|
+
res${T(": Response")},
|
|
3732
|
+
provider${T(": string")},
|
|
3733
|
+
state${T(": unknown")},
|
|
3734
|
+
)${T(": OAuthState | undefined")} {
|
|
3735
|
+
const name = \`efc_oauth_\${provider}\`;
|
|
3736
|
+
const raw = ${ts ? "(req as Request & { cookies?: Record<string, string> }).cookies?.[name]" : "req.cookies?.[name]"};
|
|
3737
|
+
res.clearCookie(name, { path: '/' });
|
|
3738
|
+
if (!raw || typeof state !== 'string') return undefined;
|
|
3739
|
+
try {
|
|
3740
|
+
const saved = JSON.parse(raw)${T(" as OAuthState")};
|
|
3741
|
+
const a = Buffer.from(String(saved.state));
|
|
3742
|
+
const b = Buffer.from(state);
|
|
3743
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b) ? saved : undefined;
|
|
3744
|
+
} catch {
|
|
3745
|
+
return undefined;
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
|
|
3749
|
+
/**
|
|
3750
|
+
* Signs in the user behind a provider identity: the account already linked to it, else the
|
|
3751
|
+
* user with the same (provider-verified) email, else a new user. New users get a random
|
|
3752
|
+
* password \u2014 they can set a real one with the forgot-password flow.
|
|
3753
|
+
*/
|
|
3754
|
+
export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuthProfile")})${T(": Promise<void>")} {
|
|
3755
|
+
const key = \`\${profile.provider}:\${profile.providerId}\`;
|
|
3756
|
+
const account = await ${db.findOne("OAuthAccount", "{ key }")};
|
|
3757
|
+
let user = account ? await ${db.findById("User", `account.${userField}`)} : null;
|
|
3758
|
+
if (!user) {
|
|
3759
|
+
if (!profile.email || !profile.emailVerified) {
|
|
3760
|
+
throw new HttpError(400, \`\${profile.provider} did not return a verified email address\`);
|
|
3761
|
+
}
|
|
3762
|
+
const email = profile.email;
|
|
3763
|
+
user =
|
|
3764
|
+
(await ${db.findOne("User", "{ email }")}) ??
|
|
3765
|
+
(await ${db.create(
|
|
3766
|
+
"User",
|
|
3767
|
+
`{
|
|
3768
|
+
name: profile.name || email.split('@')[0],
|
|
3769
|
+
email,
|
|
3770
|
+
password: await bcrypt.hash(randomToken(), 10),
|
|
3771
|
+
isVerified: true,
|
|
3772
|
+
}`
|
|
3773
|
+
)});
|
|
3774
|
+
if (account) await ${db.update("OAuthAccount", "account.id", `{ ${userField}: user.id }`)};
|
|
3775
|
+
else {
|
|
3776
|
+
await ${db.create(
|
|
3777
|
+
"OAuthAccount",
|
|
3778
|
+
`{
|
|
3779
|
+
key,
|
|
3780
|
+
provider: profile.provider,
|
|
3781
|
+
providerId: profile.providerId,
|
|
3782
|
+
email,
|
|
3783
|
+
${userField}: user.id,
|
|
3784
|
+
}`
|
|
3785
|
+
)};
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
if (!user.isActive) throw new HttpError(403, 'Account suspended');
|
|
3789
|
+
await issueToken(res, { id: user.id, role: user.role, email: user.email });
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
/** Where the browser goes once signed in. */
|
|
3793
|
+
export function successRedirect()${T(": string")} {
|
|
3794
|
+
return process.env.OAUTH_SUCCESS_REDIRECT || '/';
|
|
3795
|
+
}
|
|
3796
|
+
`;
|
|
3797
|
+
const reqRes = (r) => ts ? `(${r}: Request, res: Response)` : `(${r}, res)`;
|
|
3798
|
+
const typeImport = T("import type { Request, Response } from 'express';\n");
|
|
3799
|
+
const meta = (method, description) => `${ts ? "import type { RouteMeta } from 'express-file-cluster';\n\n" : ""}export const meta${T(": RouteMeta")} = {
|
|
3800
|
+
${method}: { description: '${description}' },
|
|
3801
|
+
};
|
|
3802
|
+
|
|
3803
|
+
`;
|
|
3804
|
+
const googleStart = `${typeImport}import { pkceChallenge, randomToken, rememberState } from '../../../lib/oauth.js';
|
|
3805
|
+
${meta("GET", 'Start "Sign in with Google": redirects to Google.')}// Redirects to Google's consent screen; Google sends the browser back to the callback route
|
|
3806
|
+
// (GOOGLE_REDIRECT_URI \u2014 register that exact URL in the Google Cloud console).
|
|
3807
|
+
export const GET = async ${reqRes("_req")} => {
|
|
3808
|
+
const state = randomToken();
|
|
3809
|
+
const nonce = randomToken();
|
|
3810
|
+
const verifier = randomToken();
|
|
3811
|
+
rememberState(res, 'google', { state, nonce, verifier });
|
|
3812
|
+
const params = new URLSearchParams({
|
|
3813
|
+
client_id: process.env.GOOGLE_CLIENT_ID ?? '',
|
|
3814
|
+
redirect_uri: process.env.GOOGLE_REDIRECT_URI ?? '',
|
|
3815
|
+
response_type: 'code',
|
|
3816
|
+
scope: 'openid email profile',
|
|
3817
|
+
state,
|
|
3818
|
+
nonce,
|
|
3819
|
+
code_challenge: pkceChallenge(verifier),
|
|
3820
|
+
code_challenge_method: 'S256',
|
|
3821
|
+
prompt: 'select_account',
|
|
3822
|
+
});
|
|
3823
|
+
res.redirect(\`https://accounts.google.com/o/oauth2/v2/auth?\${params}\`);
|
|
3824
|
+
};
|
|
3825
|
+
`;
|
|
3826
|
+
const googleCallback = `import { HttpError } from 'express-file-cluster';
|
|
3827
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
3828
|
+
${typeImport}import { signInWithOAuth, successRedirect, takeState } from '../../../lib/oauth.js';
|
|
3829
|
+
${meta("GET", "Google redirects here after sign-in; signs the user in and redirects to OAUTH_SUCCESS_REDIRECT.")}const GOOGLE_KEYS = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs'));
|
|
3830
|
+
|
|
3831
|
+
export const GET = async ${reqRes("req")} => {
|
|
3832
|
+
const { code, state, error } = req.query;
|
|
3833
|
+
if (error) throw new HttpError(400, \`Google sign-in failed: \${String(error)}\`);
|
|
3834
|
+
const saved = takeState(req, res, 'google', state);
|
|
3835
|
+
if (!saved || typeof code !== 'string') {
|
|
3836
|
+
throw new HttpError(400, 'Sign-in attempt expired or invalid \u2014 start again');
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
const tokens = await fetch('https://oauth2.googleapis.com/token', {
|
|
3840
|
+
method: 'POST',
|
|
3841
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
3842
|
+
body: new URLSearchParams({
|
|
3843
|
+
code,
|
|
3844
|
+
client_id: process.env.GOOGLE_CLIENT_ID ?? '',
|
|
3845
|
+
client_secret: process.env.GOOGLE_CLIENT_SECRET ?? '',
|
|
3846
|
+
redirect_uri: process.env.GOOGLE_REDIRECT_URI ?? '',
|
|
3847
|
+
grant_type: 'authorization_code',
|
|
3848
|
+
code_verifier: saved.verifier ?? '',
|
|
3849
|
+
}),
|
|
3850
|
+
});
|
|
3851
|
+
if (!tokens.ok) throw new HttpError(502, \`Google rejected the sign-in (\${tokens.status})\`);
|
|
3852
|
+
const { id_token } = (await tokens.json())${T(" as { id_token: string }")};
|
|
3853
|
+
|
|
3854
|
+
const { payload } = await jwtVerify(id_token, GOOGLE_KEYS, {
|
|
3855
|
+
issuer: ['https://accounts.google.com', 'accounts.google.com'],
|
|
3856
|
+
audience: process.env.GOOGLE_CLIENT_ID ?? '',
|
|
3857
|
+
});
|
|
3858
|
+
if (payload.nonce !== saved.nonce) throw new HttpError(400, 'Invalid sign-in response');
|
|
3859
|
+
|
|
3860
|
+
await signInWithOAuth(res, {
|
|
3861
|
+
provider: 'google',
|
|
3862
|
+
providerId: String(payload.sub),
|
|
3863
|
+
email: ${ts ? "payload.email as string | undefined" : "payload.email"},
|
|
3864
|
+
emailVerified: payload.email_verified === true,
|
|
3865
|
+
name: ${ts ? "payload.name as string | undefined" : "payload.name"},
|
|
3866
|
+
});
|
|
3867
|
+
res.redirect(successRedirect());
|
|
3868
|
+
};
|
|
3869
|
+
`;
|
|
3870
|
+
const appleStart = `${typeImport}import { randomToken, rememberState } from '../../../lib/oauth.js';
|
|
3871
|
+
${meta("GET", 'Start "Sign in with Apple": redirects to Apple.')}// Redirects to Apple; Apple POSTs the result to the callback route (APPLE_REDIRECT_URI \u2014
|
|
3872
|
+
// register it on the Services ID; Apple only accepts https URLs on a verified domain).
|
|
3873
|
+
export const GET = async ${reqRes("_req")} => {
|
|
3874
|
+
const state = randomToken();
|
|
3875
|
+
const nonce = randomToken();
|
|
3876
|
+
rememberState(res, 'apple', { state, nonce }, true);
|
|
3877
|
+
const params = new URLSearchParams({
|
|
3878
|
+
client_id: process.env.APPLE_CLIENT_ID ?? '',
|
|
3879
|
+
redirect_uri: process.env.APPLE_REDIRECT_URI ?? '',
|
|
3880
|
+
response_type: 'code',
|
|
3881
|
+
response_mode: 'form_post',
|
|
3882
|
+
scope: 'name email',
|
|
3883
|
+
state,
|
|
3884
|
+
nonce,
|
|
3885
|
+
});
|
|
3886
|
+
res.redirect(\`https://appleid.apple.com/auth/authorize?\${params}\`);
|
|
3887
|
+
};
|
|
3888
|
+
`;
|
|
3889
|
+
const appleCallback = `import { HttpError } from 'express-file-cluster';
|
|
3890
|
+
import { SignJWT, createRemoteJWKSet, importPKCS8, jwtVerify } from 'jose';
|
|
3891
|
+
${typeImport}import { signInWithOAuth, successRedirect, takeState } from '../../../lib/oauth.js';
|
|
3892
|
+
${meta("POST", "Apple posts here after sign-in; signs the user in and redirects to OAUTH_SUCCESS_REDIRECT.")}const APPLE_KEYS = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys'));
|
|
3893
|
+
|
|
3894
|
+
// Apple's client secret is a short-lived ES256 JWT signed with your Sign in with Apple key
|
|
3895
|
+
// (APPLE_PRIVATE_KEY: the .p8 file's contents, newlines written as \\n).
|
|
3896
|
+
async function clientSecret()${T(": Promise<string>")} {
|
|
3897
|
+
const key = await importPKCS8((process.env.APPLE_PRIVATE_KEY ?? '').replace(/\\\\n/g, '\\n'), 'ES256');
|
|
3898
|
+
return new SignJWT({})
|
|
3899
|
+
.setProtectedHeader({ alg: 'ES256', kid: process.env.APPLE_KEY_ID ?? '' })
|
|
3900
|
+
.setIssuer(process.env.APPLE_TEAM_ID ?? '')
|
|
3901
|
+
.setSubject(process.env.APPLE_CLIENT_ID ?? '')
|
|
3902
|
+
.setAudience('https://appleid.apple.com')
|
|
3903
|
+
.setIssuedAt()
|
|
3904
|
+
.setExpirationTime('5m')
|
|
3905
|
+
.sign(key);
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
export const POST = async ${reqRes("req")} => {
|
|
3909
|
+
const { code, state, user, error } = req.body ?? {};
|
|
3910
|
+
if (error) throw new HttpError(400, \`Apple sign-in failed: \${String(error)}\`);
|
|
3911
|
+
const saved = takeState(req, res, 'apple', state);
|
|
3912
|
+
if (!saved || typeof code !== 'string') {
|
|
3913
|
+
throw new HttpError(400, 'Sign-in attempt expired or invalid \u2014 start again');
|
|
3914
|
+
}
|
|
3915
|
+
|
|
3916
|
+
const tokens = await fetch('https://appleid.apple.com/auth/token', {
|
|
3917
|
+
method: 'POST',
|
|
3918
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
3919
|
+
body: new URLSearchParams({
|
|
3920
|
+
code,
|
|
3921
|
+
client_id: process.env.APPLE_CLIENT_ID ?? '',
|
|
3922
|
+
client_secret: await clientSecret(),
|
|
3923
|
+
redirect_uri: process.env.APPLE_REDIRECT_URI ?? '',
|
|
3924
|
+
grant_type: 'authorization_code',
|
|
3925
|
+
}),
|
|
3926
|
+
});
|
|
3927
|
+
if (!tokens.ok) throw new HttpError(502, \`Apple rejected the sign-in (\${tokens.status})\`);
|
|
3928
|
+
const { id_token } = (await tokens.json())${T(" as { id_token: string }")};
|
|
3929
|
+
|
|
3930
|
+
const { payload } = await jwtVerify(id_token, APPLE_KEYS, {
|
|
3931
|
+
issuer: 'https://appleid.apple.com',
|
|
3932
|
+
audience: process.env.APPLE_CLIENT_ID ?? '',
|
|
3933
|
+
});
|
|
3934
|
+
if (payload.nonce !== saved.nonce) throw new HttpError(400, 'Invalid sign-in response');
|
|
3935
|
+
|
|
3936
|
+
// Apple sends the user's name once, on their first sign-in, as a JSON form field.
|
|
3937
|
+
let name${T(": string | undefined")};
|
|
3938
|
+
try {
|
|
3939
|
+
const n = typeof user === 'string' ? JSON.parse(user).name : undefined;
|
|
3940
|
+
name = [n?.firstName, n?.lastName].filter(Boolean).join(' ') || undefined;
|
|
3941
|
+
} catch {
|
|
3942
|
+
name = undefined;
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3945
|
+
await signInWithOAuth(res, {
|
|
3946
|
+
provider: 'apple',
|
|
3947
|
+
providerId: String(payload.sub),
|
|
3948
|
+
email: ${ts ? "payload.email as string | undefined" : "payload.email"},
|
|
3949
|
+
emailVerified: payload.email_verified === true || payload.email_verified === 'true',
|
|
3950
|
+
name,
|
|
3951
|
+
});
|
|
3952
|
+
res.redirect(303, successRedirect());
|
|
3953
|
+
};
|
|
3954
|
+
`;
|
|
3955
|
+
const oauth = opts.oauth ?? {};
|
|
3956
|
+
await fs.outputFile(path.join(dest, "src", "lib", `oauth.${ext}`), lib);
|
|
3957
|
+
if (oauth.google) {
|
|
3958
|
+
await fs.outputFile(
|
|
3959
|
+
path.join(dest, "src", "api", "auth", "google", `index.${ext}`),
|
|
3960
|
+
googleStart
|
|
3961
|
+
);
|
|
3962
|
+
await fs.outputFile(
|
|
3963
|
+
path.join(dest, "src", "api", "auth", "google", `callback.${ext}`),
|
|
3964
|
+
googleCallback
|
|
3965
|
+
);
|
|
3966
|
+
}
|
|
3967
|
+
if (oauth.apple) {
|
|
3968
|
+
await fs.outputFile(path.join(dest, "src", "api", "auth", "apple", `index.${ext}`), appleStart);
|
|
3969
|
+
await fs.outputFile(
|
|
3970
|
+
path.join(dest, "src", "api", "auth", "apple", `callback.${ext}`),
|
|
3971
|
+
appleCallback
|
|
3972
|
+
);
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
// src/registry.ts
|
|
3977
|
+
import crypto2 from "crypto";
|
|
3978
|
+
import os from "os";
|
|
3979
|
+
import path2 from "path";
|
|
3980
|
+
import fs2 from "fs-extra";
|
|
3981
|
+
var VARIANTS = ["typescript", "javascript"].flatMap((language) => {
|
|
3982
|
+
const lang = language === "typescript" ? "ts" : "js";
|
|
3983
|
+
return [
|
|
3984
|
+
{
|
|
3985
|
+
key: `${lang}-mongo`,
|
|
3986
|
+
language,
|
|
3987
|
+
database: "mongodb",
|
|
3988
|
+
modelApi: "defineModel"
|
|
3989
|
+
},
|
|
3990
|
+
{
|
|
3991
|
+
key: `${lang}-pg-model`,
|
|
3992
|
+
language,
|
|
3993
|
+
database: "postgresql",
|
|
3994
|
+
modelApi: "defineModel"
|
|
3995
|
+
},
|
|
3996
|
+
{
|
|
3997
|
+
key: `${lang}-pg-table`,
|
|
3998
|
+
language,
|
|
3999
|
+
database: "postgresql",
|
|
4000
|
+
modelApi: "defineTable"
|
|
4001
|
+
}
|
|
4002
|
+
];
|
|
4003
|
+
});
|
|
4004
|
+
var user = (flag) => (o) => {
|
|
4005
|
+
o.userPortal = true;
|
|
4006
|
+
o.userFeatures = { ...o.userFeatures, [flag]: true };
|
|
4007
|
+
};
|
|
4008
|
+
var admin = (flag) => (o) => {
|
|
4009
|
+
o.adminPortal = true;
|
|
4010
|
+
o.adminFeatures = { ...o.adminFeatures, [flag]: true };
|
|
4011
|
+
};
|
|
4012
|
+
var ITEMS = [
|
|
4013
|
+
{
|
|
4014
|
+
name: "auth",
|
|
4015
|
+
title: "Authentication",
|
|
4016
|
+
description: "User & Admin models; register, login, logout and me routes",
|
|
4017
|
+
group: "auth",
|
|
4018
|
+
registryDependencies: [],
|
|
4019
|
+
docs: "Tokens are issued per `authStrategy` in efc.config \u2014 'http-only' (cookie) or 'localStorage' (Bearer). Verification emails go through the SendEmail task; `efc add email-client` makes it send real mail.",
|
|
4020
|
+
enable: (o) => {
|
|
4021
|
+
o.userPortal = true;
|
|
4022
|
+
}
|
|
4023
|
+
},
|
|
4024
|
+
{
|
|
4025
|
+
name: "user-profile",
|
|
4026
|
+
title: "Profile",
|
|
4027
|
+
description: "GET/PUT /user/profile",
|
|
4028
|
+
group: "user",
|
|
4029
|
+
registryDependencies: ["auth"],
|
|
4030
|
+
enable: user("profileViewing")
|
|
4031
|
+
},
|
|
4032
|
+
{
|
|
4033
|
+
name: "forgot-password",
|
|
4034
|
+
title: "Forgot / reset password",
|
|
4035
|
+
description: "forgot-password and reset-password routes",
|
|
4036
|
+
group: "user",
|
|
4037
|
+
registryDependencies: ["auth"],
|
|
4038
|
+
enable: user("forgotPassword")
|
|
4039
|
+
},
|
|
4040
|
+
{
|
|
4041
|
+
name: "account-security",
|
|
4042
|
+
title: "Account security",
|
|
4043
|
+
description: "change password, 2FA, sessions, token refresh",
|
|
4044
|
+
group: "user",
|
|
4045
|
+
registryDependencies: ["auth"],
|
|
4046
|
+
enable: user("accountSecurity")
|
|
4047
|
+
},
|
|
4048
|
+
{
|
|
4049
|
+
name: "email-verification",
|
|
4050
|
+
title: "Email verification",
|
|
4051
|
+
description: "verify-email and resend-verification routes",
|
|
4052
|
+
group: "user",
|
|
4053
|
+
registryDependencies: ["auth"],
|
|
4054
|
+
enable: user("emailVerification")
|
|
4055
|
+
},
|
|
4056
|
+
{
|
|
4057
|
+
name: "account-settings",
|
|
4058
|
+
title: "Account settings",
|
|
4059
|
+
description: "avatar, settings, account, dashboard, activity",
|
|
4060
|
+
group: "user",
|
|
4061
|
+
registryDependencies: ["auth"],
|
|
4062
|
+
enable: user("accountSettings")
|
|
4063
|
+
},
|
|
4064
|
+
{
|
|
4065
|
+
name: "notifications",
|
|
4066
|
+
title: "Notifications",
|
|
4067
|
+
description: "Notification model + /user/notifications",
|
|
4068
|
+
group: "user",
|
|
4069
|
+
registryDependencies: ["auth"],
|
|
4070
|
+
enable: user("notifications")
|
|
4071
|
+
},
|
|
4072
|
+
{
|
|
4073
|
+
name: "files",
|
|
4074
|
+
title: "Files, favorites & bookmarks",
|
|
4075
|
+
description: "File model + files, favorites, bookmarks, search",
|
|
4076
|
+
group: "user",
|
|
4077
|
+
registryDependencies: ["auth"],
|
|
4078
|
+
enable: user("filesAndMedia")
|
|
4079
|
+
},
|
|
4080
|
+
{
|
|
4081
|
+
name: "billing",
|
|
4082
|
+
title: "API keys & billing",
|
|
4083
|
+
description: "Plan, Subscription, Invoice, ApiKey models + user billing routes",
|
|
4084
|
+
group: "user",
|
|
4085
|
+
registryDependencies: ["auth"],
|
|
4086
|
+
enable: user("apiAndBilling")
|
|
4087
|
+
},
|
|
4088
|
+
{
|
|
4089
|
+
name: "support",
|
|
4090
|
+
title: "Support tickets",
|
|
4091
|
+
description: "SupportTicket model + create & view own tickets",
|
|
4092
|
+
group: "user",
|
|
4093
|
+
registryDependencies: ["auth"],
|
|
4094
|
+
enable: user("support")
|
|
4095
|
+
},
|
|
4096
|
+
{
|
|
4097
|
+
name: "admin",
|
|
4098
|
+
title: "Admin portal",
|
|
4099
|
+
description: "admin dashboard stats, behind requireAuth('admin')",
|
|
4100
|
+
group: "admin",
|
|
4101
|
+
registryDependencies: ["auth"],
|
|
4102
|
+
enable: (o) => {
|
|
4103
|
+
o.adminPortal = true;
|
|
4104
|
+
}
|
|
4105
|
+
},
|
|
4106
|
+
{
|
|
4107
|
+
name: "admin-users",
|
|
4108
|
+
title: "User management",
|
|
4109
|
+
description: "list/create/suspend/verify/export users + stats",
|
|
4110
|
+
group: "admin",
|
|
4111
|
+
registryDependencies: ["admin"],
|
|
4112
|
+
enable: admin("userManagement")
|
|
4113
|
+
},
|
|
4114
|
+
{
|
|
4115
|
+
name: "admin-admins",
|
|
4116
|
+
title: "Admin & role management",
|
|
4117
|
+
description: "manage other admins + Role model",
|
|
4118
|
+
group: "admin",
|
|
4119
|
+
registryDependencies: ["admin"],
|
|
4120
|
+
enable: admin("adminManagement")
|
|
4121
|
+
},
|
|
4122
|
+
{
|
|
4123
|
+
name: "admin-analytics",
|
|
4124
|
+
title: "Analytics",
|
|
4125
|
+
description: "users, revenue, traffic dashboards",
|
|
4126
|
+
group: "admin",
|
|
4127
|
+
registryDependencies: ["admin"],
|
|
4128
|
+
enable: admin("analytics")
|
|
4129
|
+
},
|
|
4130
|
+
{
|
|
4131
|
+
name: "admin-content",
|
|
4132
|
+
title: "Content management",
|
|
4133
|
+
description: "FAQ, Blog, Category models + admin routes",
|
|
4134
|
+
group: "admin",
|
|
4135
|
+
registryDependencies: ["admin"],
|
|
4136
|
+
enable: admin("contentManagement")
|
|
4137
|
+
},
|
|
4138
|
+
{
|
|
4139
|
+
name: "admin-billing",
|
|
4140
|
+
title: "Billing management",
|
|
4141
|
+
description: "Plan, Coupon models + plans, coupons, subscriptions",
|
|
4142
|
+
group: "admin",
|
|
4143
|
+
registryDependencies: ["admin"],
|
|
4144
|
+
enable: admin("billingManagement")
|
|
4145
|
+
},
|
|
4146
|
+
{
|
|
4147
|
+
name: "admin-support",
|
|
4148
|
+
title: "Support desk",
|
|
4149
|
+
description: "view & respond to support tickets",
|
|
4150
|
+
group: "admin",
|
|
4151
|
+
registryDependencies: ["admin"],
|
|
4152
|
+
enable: admin("supportManagement")
|
|
4153
|
+
},
|
|
4154
|
+
{
|
|
4155
|
+
name: "admin-logs",
|
|
4156
|
+
title: "Notifications & audit logs",
|
|
4157
|
+
description: "broadcast + audit/activity/security logs",
|
|
4158
|
+
group: "admin",
|
|
4159
|
+
registryDependencies: ["admin"],
|
|
4160
|
+
enable: admin("notificationsAndLogs")
|
|
4161
|
+
},
|
|
4162
|
+
{
|
|
4163
|
+
name: "admin-system",
|
|
4164
|
+
title: "System settings & health",
|
|
4165
|
+
description: "app settings, health check, cache",
|
|
4166
|
+
group: "admin",
|
|
4167
|
+
registryDependencies: ["admin"],
|
|
4168
|
+
enable: admin("systemSettings")
|
|
4169
|
+
},
|
|
4170
|
+
{
|
|
4171
|
+
name: "auth@google",
|
|
4172
|
+
title: "Sign in with Google",
|
|
4173
|
+
description: "GET /auth/google \u2192 Google \u2192 /auth/google/callback (OpenID Connect + PKCE)",
|
|
4174
|
+
group: "auth",
|
|
4175
|
+
registryDependencies: ["auth"],
|
|
4176
|
+
docs: 'Create an OAuth client (type "Web application") at console.cloud.google.com \u2192 APIs & Services \u2192 Credentials, and add GOOGLE_REDIRECT_URI as an authorised redirect URI. Send users to GET /v1/api/auth/google to sign in.',
|
|
4177
|
+
prompts: [
|
|
4178
|
+
{
|
|
4179
|
+
name: "GOOGLE_CLIENT_ID",
|
|
4180
|
+
type: "text",
|
|
4181
|
+
message: "Google OAuth client ID:",
|
|
4182
|
+
placeholder: "1234-abc.apps.googleusercontent.com",
|
|
4183
|
+
required: true,
|
|
4184
|
+
pattern: "\\.apps\\.googleusercontent\\.com$",
|
|
4185
|
+
patternMessage: "Google client IDs end in .apps.googleusercontent.com",
|
|
4186
|
+
note: "console.cloud.google.com \u2192 APIs & Services \u2192 Credentials \u2192 Create credentials \u2192 OAuth client ID (Web application)."
|
|
4187
|
+
},
|
|
4188
|
+
{
|
|
4189
|
+
name: "GOOGLE_CLIENT_SECRET",
|
|
4190
|
+
type: "password",
|
|
4191
|
+
message: "Google OAuth client secret:",
|
|
4192
|
+
required: true
|
|
4193
|
+
},
|
|
4194
|
+
{
|
|
4195
|
+
name: "GOOGLE_REDIRECT_URI",
|
|
4196
|
+
type: "text",
|
|
4197
|
+
message: "Redirect URI (register it on the client):",
|
|
4198
|
+
default: "http://localhost:3000/v1/api/auth/google/callback"
|
|
4199
|
+
},
|
|
4200
|
+
{
|
|
4201
|
+
name: "OAUTH_SUCCESS_REDIRECT",
|
|
4202
|
+
type: "text",
|
|
4203
|
+
message: "Where to send the browser after sign-in:",
|
|
4204
|
+
default: "http://localhost:3000/"
|
|
4205
|
+
}
|
|
4206
|
+
],
|
|
4207
|
+
enable: (o) => {
|
|
4208
|
+
o.userPortal = true;
|
|
4209
|
+
o.oauth = { ...o.oauth, google: true };
|
|
4210
|
+
}
|
|
4211
|
+
},
|
|
4212
|
+
{
|
|
4213
|
+
name: "auth@apple",
|
|
4214
|
+
title: "Sign in with Apple",
|
|
4215
|
+
description: "GET /auth/apple \u2192 Apple \u2192 POST /auth/apple/callback (form_post)",
|
|
4216
|
+
group: "auth",
|
|
4217
|
+
registryDependencies: ["auth"],
|
|
4218
|
+
docs: "Needs a Services ID with Sign in with Apple enabled, its return URL set to APPLE_REDIRECT_URI (https, on a verified domain \u2014 Apple rejects localhost), and a Sign in with Apple key (.p8). Send users to GET /v1/api/auth/apple to sign in.",
|
|
4219
|
+
prompts: [
|
|
4220
|
+
{
|
|
4221
|
+
name: "APPLE_CLIENT_ID",
|
|
4222
|
+
type: "text",
|
|
4223
|
+
message: "Services ID (client ID):",
|
|
4224
|
+
placeholder: "com.yourapp.signin",
|
|
4225
|
+
required: true,
|
|
4226
|
+
note: "developer.apple.com \u2192 Certificates, IDs & Profiles: a Services ID with Sign in with Apple, and a key with Sign in with Apple (.p8)."
|
|
4227
|
+
},
|
|
4228
|
+
{
|
|
4229
|
+
name: "APPLE_TEAM_ID",
|
|
4230
|
+
type: "text",
|
|
4231
|
+
message: "Team ID:",
|
|
4232
|
+
required: true,
|
|
4233
|
+
pattern: "^[A-Z0-9]{10}$",
|
|
4234
|
+
patternMessage: "Team IDs are 10 characters (A-Z, 0-9)"
|
|
4235
|
+
},
|
|
4236
|
+
{
|
|
4237
|
+
name: "APPLE_KEY_ID",
|
|
4238
|
+
type: "text",
|
|
4239
|
+
message: "Key ID:",
|
|
4240
|
+
required: true,
|
|
4241
|
+
pattern: "^[A-Z0-9]{10}$",
|
|
4242
|
+
patternMessage: "Key IDs are 10 characters (A-Z, 0-9)"
|
|
4243
|
+
},
|
|
4244
|
+
{
|
|
4245
|
+
name: "APPLE_PRIVATE_KEY",
|
|
4246
|
+
type: "file",
|
|
4247
|
+
message: "Path to the .p8 key file:",
|
|
4248
|
+
placeholder: "./AuthKey_ABCDE12345.p8",
|
|
4249
|
+
required: true,
|
|
4250
|
+
pattern: "BEGIN PRIVATE KEY",
|
|
4251
|
+
patternMessage: "That file doesn't look like a .p8 private key"
|
|
4252
|
+
},
|
|
4253
|
+
{
|
|
4254
|
+
name: "APPLE_REDIRECT_URI",
|
|
4255
|
+
type: "text",
|
|
4256
|
+
message: "Return URL (https, registered on the Services ID):",
|
|
4257
|
+
default: "https://api.yourapp.com/v1/api/auth/apple/callback",
|
|
4258
|
+
pattern: "^https://",
|
|
4259
|
+
patternMessage: "Apple only accepts https return URLs"
|
|
4260
|
+
},
|
|
4261
|
+
{
|
|
4262
|
+
name: "OAUTH_SUCCESS_REDIRECT",
|
|
4263
|
+
type: "text",
|
|
4264
|
+
message: "Where to send the browser after sign-in:",
|
|
4265
|
+
default: "http://localhost:3000/"
|
|
4266
|
+
}
|
|
4267
|
+
],
|
|
4268
|
+
enable: (o) => {
|
|
4269
|
+
o.userPortal = true;
|
|
4270
|
+
o.oauth = { ...o.oauth, apple: true };
|
|
4271
|
+
}
|
|
4272
|
+
},
|
|
4273
|
+
{
|
|
4274
|
+
name: "email-client",
|
|
4275
|
+
aliases: ["mailer"],
|
|
4276
|
+
title: "Email client",
|
|
4277
|
+
description: "SendEmail task over SMTP (nodemailer) \u2014 Gmail or any SMTP server",
|
|
4278
|
+
group: "integrations",
|
|
4279
|
+
registryDependencies: [],
|
|
4280
|
+
replaces: ["src/tasks/SendEmail.ts", "src/tasks/SendEmail.js"],
|
|
4281
|
+
docs: "Every enqueue('SendEmail', { to, subject, body }) now sends real mail. Change SMTP settings in .env.",
|
|
4282
|
+
prompts: [
|
|
4283
|
+
{
|
|
4284
|
+
name: "provider",
|
|
4285
|
+
type: "select",
|
|
4286
|
+
message: "Email provider:",
|
|
4287
|
+
options: [
|
|
4288
|
+
{
|
|
4289
|
+
value: "gmail",
|
|
4290
|
+
label: "Gmail",
|
|
4291
|
+
hint: "smtp.gmail.com, preconfigured",
|
|
4292
|
+
env: { SMTP_HOST: "smtp.gmail.com", SMTP_PORT: "465" }
|
|
4293
|
+
},
|
|
4294
|
+
{ value: "custom", label: "Other (custom SMTP)", hint: "you'll provide host + port" }
|
|
4295
|
+
]
|
|
4296
|
+
},
|
|
4297
|
+
{
|
|
4298
|
+
name: "SMTP_HOST",
|
|
4299
|
+
type: "text",
|
|
4300
|
+
message: "SMTP host:",
|
|
4301
|
+
placeholder: "smtp.mailtrap.io",
|
|
4302
|
+
required: true,
|
|
4303
|
+
when: { provider: "custom" }
|
|
4304
|
+
},
|
|
4305
|
+
{
|
|
4306
|
+
name: "SMTP_PORT",
|
|
4307
|
+
type: "text",
|
|
4308
|
+
message: "SMTP port:",
|
|
4309
|
+
default: "587",
|
|
4310
|
+
when: { provider: "custom" }
|
|
4311
|
+
},
|
|
4312
|
+
{
|
|
4313
|
+
name: "SMTP_USER",
|
|
4314
|
+
type: "text",
|
|
4315
|
+
message: "Gmail address:",
|
|
4316
|
+
placeholder: "you@gmail.com",
|
|
4317
|
+
required: true,
|
|
4318
|
+
when: { provider: "gmail" }
|
|
4319
|
+
},
|
|
4320
|
+
{
|
|
4321
|
+
name: "SMTP_USER",
|
|
4322
|
+
type: "text",
|
|
4323
|
+
message: "SMTP username / email:",
|
|
4324
|
+
placeholder: "you@example.com",
|
|
4325
|
+
required: true,
|
|
4326
|
+
when: { provider: "custom" }
|
|
4327
|
+
},
|
|
4328
|
+
{
|
|
4329
|
+
name: "SMTP_PASS",
|
|
4330
|
+
type: "password",
|
|
4331
|
+
message: "Gmail app password (16 characters):",
|
|
4332
|
+
required: true,
|
|
4333
|
+
stripSpaces: true,
|
|
4334
|
+
pattern: "^\\S{16}$",
|
|
4335
|
+
patternMessage: "Gmail app passwords are 16 characters \u2014 this looks like a regular password, not an app password",
|
|
4336
|
+
when: { provider: "gmail" },
|
|
4337
|
+
note: "Google no longer accepts your regular password for SMTP. Generate a 16-character App Password: Google Account \u2192 Security \u2192 2-Step Verification \u2192 App passwords."
|
|
4338
|
+
},
|
|
4339
|
+
{
|
|
4340
|
+
name: "SMTP_PASS",
|
|
4341
|
+
type: "password",
|
|
4342
|
+
message: "SMTP password:",
|
|
4343
|
+
required: true,
|
|
4344
|
+
when: { provider: "custom" }
|
|
4345
|
+
},
|
|
4346
|
+
{ name: "SMTP_FROM", type: "text", message: "From address:", default: "{{SMTP_USER}}" },
|
|
4347
|
+
{
|
|
4348
|
+
name: "APP_URL",
|
|
4349
|
+
type: "text",
|
|
4350
|
+
message: "App URL (used in email links):",
|
|
4351
|
+
default: "http://localhost:3000"
|
|
4352
|
+
}
|
|
4353
|
+
],
|
|
4354
|
+
enable: (o) => {
|
|
4355
|
+
o.mailer = true;
|
|
4356
|
+
}
|
|
4357
|
+
}
|
|
4358
|
+
];
|
|
4359
|
+
var BY_NAME = new Map(
|
|
4360
|
+
ITEMS.flatMap((i) => [i.name, ...i.aliases ?? []].map((n) => [n, i]))
|
|
4361
|
+
);
|
|
4362
|
+
function getItem(name) {
|
|
4363
|
+
const item = BY_NAME.get(name);
|
|
4364
|
+
if (!item) throw new Error(`Unknown registry item "${name}"`);
|
|
4365
|
+
return item;
|
|
4366
|
+
}
|
|
4367
|
+
function withDependencies(names) {
|
|
4368
|
+
const out = [];
|
|
4369
|
+
const visit = (alias, stack) => {
|
|
4370
|
+
const name = getItem(alias).name;
|
|
4371
|
+
if (out.includes(name)) return;
|
|
4372
|
+
if (stack.includes(name)) throw new Error(`Dependency cycle: ${[...stack, name].join(" \u2192 ")}`);
|
|
4373
|
+
for (const dep of getItem(name).registryDependencies) visit(dep, [...stack, name]);
|
|
4374
|
+
out.push(name);
|
|
4375
|
+
};
|
|
4376
|
+
for (const name of names) visit(name, []);
|
|
4377
|
+
return out;
|
|
4378
|
+
}
|
|
4379
|
+
function registryOptions(v, items = [], extra = {}) {
|
|
4380
|
+
const o = {
|
|
4381
|
+
projectName: "efc-app",
|
|
4382
|
+
language: v.language,
|
|
4383
|
+
database: v.database,
|
|
4384
|
+
...v.database === "postgresql" && { modelApi: v.modelApi },
|
|
4385
|
+
authStrategy: "http-only",
|
|
4386
|
+
cluster: true,
|
|
4387
|
+
tasks: true,
|
|
4388
|
+
taskBackend: v.database === "postgresql" ? "pg-boss" : "bullmq",
|
|
4389
|
+
routeDocs: true,
|
|
4390
|
+
userPortal: false,
|
|
4391
|
+
adminPortal: false,
|
|
4392
|
+
adminFeatures: { ...NO_ADMIN_FEATURES },
|
|
4393
|
+
userFeatures: { ...NO_USER_FEATURES },
|
|
4394
|
+
rbac: true,
|
|
4395
|
+
mailer: false,
|
|
4396
|
+
...extra,
|
|
4397
|
+
registry: true
|
|
4398
|
+
};
|
|
4399
|
+
for (const name of withDependencies(items)) getItem(name).enable(o);
|
|
4400
|
+
return o;
|
|
4401
|
+
}
|
|
4402
|
+
function isManagedPath(p2) {
|
|
4403
|
+
return p2 === "package.json" || p2 === ".env" || p2 === ".env.example" || p2 === "AGENTS.md" || p2 === ".gitignore" || p2 === "tsconfig.json" || /^efc\.config\.[jt]s$/.test(p2) || /^src\/index\.[jt]s$/.test(p2) || /^src\/model\/index\.[jt]s$/.test(p2) || p2.startsWith("src/migrations/");
|
|
4404
|
+
}
|
|
4405
|
+
function sha256(content) {
|
|
4406
|
+
return crypto2.createHash("sha256").update(content).digest("hex");
|
|
4407
|
+
}
|
|
4408
|
+
async function writeManifest(dest, opts, items) {
|
|
4409
|
+
const pkg = await fs2.readJson(path2.join(dest, "package.json"));
|
|
4410
|
+
const files = {};
|
|
4411
|
+
const walk = async (dir) => {
|
|
4412
|
+
for (const entry of await fs2.readdir(dir, { withFileTypes: true })) {
|
|
4413
|
+
if (entry.name === "node_modules") continue;
|
|
4414
|
+
const abs = path2.join(dir, entry.name);
|
|
4415
|
+
if (entry.isDirectory()) await walk(abs);
|
|
4416
|
+
else {
|
|
4417
|
+
const rel = path2.relative(dest, abs).split(path2.sep).join("/");
|
|
4418
|
+
if (!isManagedPath(rel)) files[rel] = sha256(await fs2.readFile(abs, "utf8"));
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
};
|
|
4422
|
+
await walk(dest);
|
|
4423
|
+
const manifest = {
|
|
4424
|
+
registry: (pkg.dependencies["express-file-cluster"] ?? "").replace(/^[^\d]*/, ""),
|
|
4425
|
+
language: opts.language,
|
|
4426
|
+
database: opts.database,
|
|
4427
|
+
modelApi: opts.database === "postgresql" ? opts.modelApi ?? "defineTable" : "defineModel",
|
|
4428
|
+
tasks: opts.tasks ? opts.taskBackend ?? "bullmq" : null,
|
|
4429
|
+
items: [...withDependencies(items)].sort(),
|
|
4430
|
+
files: Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)))
|
|
4431
|
+
};
|
|
4432
|
+
await fs2.writeFile(path2.join(dest, "efc.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
4433
|
+
}
|
|
4434
|
+
var isEnvName = (name) => /^[A-Z][A-Z0-9_]*$/.test(name);
|
|
4435
|
+
function promptApplies(p2, answers) {
|
|
4436
|
+
return Object.entries(p2.when ?? {}).every(([k, v]) => answers[k] === v);
|
|
4437
|
+
}
|
|
4438
|
+
function promptDefault(p2, answers) {
|
|
4439
|
+
return p2.default?.replace(/\{\{(\w+)\}\}/g, (_, k) => answers[k] ?? "");
|
|
4440
|
+
}
|
|
4441
|
+
function normalizeAnswer(p2, raw, cwd = process.cwd()) {
|
|
4442
|
+
let value = p2.stripSpaces ? raw.replace(/\s/g, "") : raw.trim();
|
|
4443
|
+
if (!value) return p2.required ? { error: "Required" } : { value };
|
|
4444
|
+
if (p2.type === "file") {
|
|
4445
|
+
const file = path2.resolve(cwd, value.replace(/^~(?=$|\/)/, os.homedir()));
|
|
4446
|
+
if (!fs2.existsSync(file)) return { error: `No such file: ${file}` };
|
|
4447
|
+
value = fs2.readFileSync(file, "utf8").trim();
|
|
4448
|
+
}
|
|
4449
|
+
if (p2.pattern && !new RegExp(p2.pattern).test(value)) {
|
|
4450
|
+
return { error: p2.patternMessage ?? `Must match ${p2.pattern}` };
|
|
4451
|
+
}
|
|
4452
|
+
if (p2.type === "file") value = value.replace(/\r?\n/g, "\\n");
|
|
4453
|
+
return { value };
|
|
4454
|
+
}
|
|
4455
|
+
function envFromAnswers(prompts, answers) {
|
|
4456
|
+
const env = {};
|
|
4457
|
+
for (const p2 of prompts) {
|
|
4458
|
+
if (!(p2.name in answers) || !promptApplies(p2, answers)) continue;
|
|
4459
|
+
if (isEnvName(p2.name)) env[p2.name] = answers[p2.name];
|
|
4460
|
+
const chosen = p2.options?.find((o) => o.value === answers[p2.name]);
|
|
4461
|
+
Object.assign(env, chosen?.env ?? {});
|
|
4462
|
+
}
|
|
4463
|
+
return env;
|
|
4464
|
+
}
|
|
4465
|
+
function itemPrompts(items) {
|
|
4466
|
+
const out = [];
|
|
4467
|
+
for (const name of items) {
|
|
4468
|
+
for (const p2 of getItem(name).prompts ?? []) {
|
|
4469
|
+
const dup = out.some(
|
|
4470
|
+
(q) => q.name === p2.name && JSON.stringify(q.when) === JSON.stringify(p2.when)
|
|
4471
|
+
);
|
|
4472
|
+
if (!dup) out.push(p2);
|
|
4473
|
+
}
|
|
4474
|
+
}
|
|
4475
|
+
return out;
|
|
4476
|
+
}
|
|
4477
|
+
async function setEnvValues(file, env) {
|
|
4478
|
+
const line = (k, v) => !/[\s#"'`]/.test(v) ? `${k}=${v}` : v.includes("'") ? `${k}="${v.replace(/"/g, '\\"')}"` : `${k}='${v}'`;
|
|
4479
|
+
const lines = await fs2.pathExists(file) ? (await fs2.readFile(file, "utf8")).split("\n") : [];
|
|
4480
|
+
const rest = { ...env };
|
|
4481
|
+
const out = lines.map((l) => {
|
|
4482
|
+
const key = /^([A-Za-z0-9_]+)=/.exec(l)?.[1];
|
|
4483
|
+
if (key === void 0 || !(key in rest)) return l;
|
|
4484
|
+
const v = rest[key];
|
|
4485
|
+
delete rest[key];
|
|
4486
|
+
return line(key, v);
|
|
4487
|
+
});
|
|
4488
|
+
for (const [k, v] of Object.entries(rest)) out.push(line(k, v));
|
|
4489
|
+
await fs2.writeFile(file, out.join("\n"));
|
|
4490
|
+
}
|
|
3474
4491
|
|
|
3475
4492
|
// src/index.ts
|
|
3476
4493
|
function cancel2(msg = "Cancelled") {
|
|
@@ -3503,190 +4520,125 @@ async function main() {
|
|
|
3503
4520
|
]
|
|
3504
4521
|
});
|
|
3505
4522
|
if (p.isCancel(database)) cancel2();
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
{ value: "localStorage", label: "localStorage", hint: "bearer token \u2014 for SPAs" }
|
|
3511
|
-
]
|
|
3512
|
-
});
|
|
3513
|
-
if (p.isCancel(authStrategy)) cancel2();
|
|
3514
|
-
const features = await p.multiselect({
|
|
3515
|
-
message: "Features: (space to toggle, enter to confirm)",
|
|
3516
|
-
options: [
|
|
3517
|
-
{ value: "cluster", label: "Multi-core clustering", hint: "Node cluster module" },
|
|
3518
|
-
{ value: "tasks", label: "Background tasks", hint: "BullMQ / pg-boss" },
|
|
3519
|
-
{ value: "routeDocs", label: "API route documentation", hint: "meta exports + dashboard" },
|
|
3520
|
-
{ value: "userPortal", label: "User portal", hint: "auth, profile, billing routes" },
|
|
3521
|
-
{ value: "adminPortal", label: "Admin portal", hint: "dashboard, user mgmt, analytics" },
|
|
3522
|
-
{ value: "rbac", label: "Role-based access control", hint: "requireAuth('role') middleware" },
|
|
3523
|
-
{ value: "mailer", label: "Mailer", hint: "nodemailer + SMTP" }
|
|
3524
|
-
],
|
|
3525
|
-
initialValues: ["cluster", "tasks", "routeDocs", "userPortal", "adminPortal", "rbac"],
|
|
3526
|
-
required: false
|
|
3527
|
-
});
|
|
3528
|
-
if (p.isCancel(features)) cancel2();
|
|
3529
|
-
const selected = new Set(features);
|
|
3530
|
-
let userFeatures = NO_USER_FEATURES;
|
|
3531
|
-
if (selected.has("userPortal")) {
|
|
3532
|
-
const chosen = await p.multiselect({
|
|
3533
|
-
message: "User portal features: (space to toggle, enter to confirm)",
|
|
4523
|
+
let modelApi2;
|
|
4524
|
+
if (database === "postgresql") {
|
|
4525
|
+
const api = await p.select({
|
|
4526
|
+
message: "Models:",
|
|
3534
4527
|
options: [
|
|
3535
|
-
{
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
{ value: "
|
|
3541
|
-
|
|
3542
|
-
{ value: "apiAndBilling", label: "API keys & billing", hint: "api-keys, plans, subscription, invoices" },
|
|
3543
|
-
{ value: "support", label: "Support tickets", hint: "create & view own tickets" }
|
|
3544
|
-
],
|
|
3545
|
-
initialValues: ["profileViewing", "forgotPassword", "accountSecurity", "emailVerification", "accountSettings", "notifications", "filesAndMedia", "apiAndBilling", "support"],
|
|
3546
|
-
required: false
|
|
4528
|
+
{
|
|
4529
|
+
value: "defineTable",
|
|
4530
|
+
label: "defineTable",
|
|
4531
|
+
hint: "PostgreSQL-native \u2014 foreign keys, relations, include, Prisma-style queries"
|
|
4532
|
+
},
|
|
4533
|
+
{ value: "defineModel", label: "defineModel", hint: "portable \u2014 the same API on MongoDB" }
|
|
4534
|
+
]
|
|
3547
4535
|
});
|
|
3548
|
-
if (p.isCancel(
|
|
3549
|
-
|
|
3550
|
-
userFeatures = {
|
|
3551
|
-
profileViewing: uSet.has("profileViewing"),
|
|
3552
|
-
forgotPassword: uSet.has("forgotPassword"),
|
|
3553
|
-
accountSecurity: uSet.has("accountSecurity"),
|
|
3554
|
-
emailVerification: uSet.has("emailVerification"),
|
|
3555
|
-
accountSettings: uSet.has("accountSettings"),
|
|
3556
|
-
notifications: uSet.has("notifications"),
|
|
3557
|
-
filesAndMedia: uSet.has("filesAndMedia"),
|
|
3558
|
-
apiAndBilling: uSet.has("apiAndBilling"),
|
|
3559
|
-
support: uSet.has("support")
|
|
3560
|
-
};
|
|
4536
|
+
if (p.isCancel(api)) cancel2();
|
|
4537
|
+
modelApi2 = api;
|
|
3561
4538
|
}
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
4539
|
+
const taskBackend = await p.select({
|
|
4540
|
+
message: "Task queue backend:",
|
|
4541
|
+
options: database === "postgresql" ? [
|
|
4542
|
+
{ value: "pg-boss", label: "pg-boss", hint: "uses your PostgreSQL \u2014 no Redis" },
|
|
4543
|
+
{ value: "bullmq", label: "BullMQ", hint: "Redis" }
|
|
4544
|
+
] : [{ value: "bullmq", label: "BullMQ", hint: "Redis" }]
|
|
4545
|
+
});
|
|
4546
|
+
if (p.isCancel(taskBackend)) cancel2();
|
|
4547
|
+
const cluster = await p.confirm({
|
|
4548
|
+
message: "Multi-core clustering? (one worker per CPU in production)",
|
|
4549
|
+
initialValue: true
|
|
4550
|
+
});
|
|
4551
|
+
if (p.isCancel(cluster)) cancel2();
|
|
4552
|
+
const groupLabel = {
|
|
4553
|
+
auth: "Auth",
|
|
4554
|
+
user: "User portal",
|
|
4555
|
+
admin: "Admin portal",
|
|
4556
|
+
integrations: "Integrations"
|
|
4557
|
+
};
|
|
4558
|
+
const groups = {};
|
|
4559
|
+
for (const item of ITEMS) {
|
|
4560
|
+
(groups[groupLabel[item.group]] ??= []).push({
|
|
4561
|
+
value: item.name,
|
|
4562
|
+
label: item.title,
|
|
4563
|
+
hint: item.description
|
|
3578
4564
|
});
|
|
3579
|
-
if (p.isCancel(chosen)) cancel2();
|
|
3580
|
-
const aSet = new Set(chosen);
|
|
3581
|
-
adminFeatures = {
|
|
3582
|
-
userManagement: aSet.has("userManagement"),
|
|
3583
|
-
adminManagement: aSet.has("adminManagement"),
|
|
3584
|
-
analytics: aSet.has("analytics"),
|
|
3585
|
-
contentManagement: aSet.has("contentManagement"),
|
|
3586
|
-
billingManagement: aSet.has("billingManagement"),
|
|
3587
|
-
supportManagement: aSet.has("supportManagement"),
|
|
3588
|
-
notificationsAndLogs: aSet.has("notificationsAndLogs"),
|
|
3589
|
-
systemSettings: aSet.has("systemSettings")
|
|
3590
|
-
};
|
|
3591
4565
|
}
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
4566
|
+
const picked = await p.groupMultiselect({
|
|
4567
|
+
message: `Add features now? ${pc.dim("(space to toggle, enter to confirm \u2014 or add any later with `efc add <item>`)")}`,
|
|
4568
|
+
options: groups,
|
|
4569
|
+
required: false
|
|
4570
|
+
});
|
|
4571
|
+
if (p.isCancel(picked)) cancel2();
|
|
4572
|
+
const items = withDependencies(picked);
|
|
4573
|
+
let authStrategy = "http-only";
|
|
4574
|
+
if (items.includes("auth")) {
|
|
4575
|
+
const strategy = await p.select({
|
|
4576
|
+
message: "Authentication strategy:",
|
|
3596
4577
|
options: [
|
|
3597
|
-
{ value: "
|
|
3598
|
-
{
|
|
3599
|
-
value: "pg-boss",
|
|
3600
|
-
label: "pg-boss",
|
|
3601
|
-
hint: database === "postgresql" ? "uses your PostgreSQL \u2014 no Redis" : "needs PostgreSQL"
|
|
3602
|
-
}
|
|
4578
|
+
{ value: "http-only", label: "http-only", hint: "secure cookie \u2014 recommended for SSR" },
|
|
4579
|
+
{ value: "localStorage", label: "localStorage", hint: "bearer token \u2014 for SPAs" }
|
|
3603
4580
|
]
|
|
3604
4581
|
});
|
|
3605
|
-
if (p.isCancel(
|
|
3606
|
-
|
|
4582
|
+
if (p.isCancel(strategy)) cancel2();
|
|
4583
|
+
authStrategy = strategy;
|
|
3607
4584
|
}
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
if (p.isCancel(provider)) cancel2();
|
|
3622
|
-
smtpProvider = provider;
|
|
3623
|
-
if (smtpProvider === "custom") {
|
|
3624
|
-
const host = await p.text({
|
|
3625
|
-
message: "SMTP host:",
|
|
3626
|
-
placeholder: "smtp.mailtrap.io",
|
|
3627
|
-
validate: (v) => !v.trim() ? "SMTP host is required" : void 0
|
|
3628
|
-
});
|
|
3629
|
-
if (p.isCancel(host)) cancel2();
|
|
3630
|
-
smtpHost = host;
|
|
3631
|
-
const port = await p.text({
|
|
3632
|
-
message: "SMTP port:",
|
|
3633
|
-
placeholder: "587",
|
|
3634
|
-
defaultValue: "587"
|
|
4585
|
+
const answers = {};
|
|
4586
|
+
const prompts = itemPrompts(items);
|
|
4587
|
+
for (const q of prompts) {
|
|
4588
|
+
if (!promptApplies(q, answers)) continue;
|
|
4589
|
+
if (q.note) p.note(q.note);
|
|
4590
|
+
if (q.type === "select") {
|
|
4591
|
+
const choice = await p.select({
|
|
4592
|
+
message: q.message,
|
|
4593
|
+
options: (q.options ?? []).map((o) => ({
|
|
4594
|
+
value: o.value,
|
|
4595
|
+
label: o.label,
|
|
4596
|
+
...o.hint !== void 0 && { hint: o.hint }
|
|
4597
|
+
}))
|
|
3635
4598
|
});
|
|
3636
|
-
if (p.isCancel(
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
const user = await p.text({
|
|
3640
|
-
message: smtpProvider === "gmail" ? "Gmail address:" : "SMTP username / email:",
|
|
3641
|
-
placeholder: "you@example.com",
|
|
3642
|
-
validate: (v) => !v.trim() ? "Email is required" : void 0
|
|
3643
|
-
});
|
|
3644
|
-
if (p.isCancel(user)) cancel2();
|
|
3645
|
-
smtpUser = user;
|
|
3646
|
-
if (smtpProvider === "gmail") {
|
|
3647
|
-
p.note(
|
|
3648
|
-
"Google no longer accepts your regular account password for SMTP.\nGenerate a 16-character App Password: Google Account -> Security -> 2-Step Verification -> App passwords.\nEnter it below without spaces (not your Gmail login password).",
|
|
3649
|
-
"Gmail app password required"
|
|
3650
|
-
);
|
|
4599
|
+
if (p.isCancel(choice)) cancel2();
|
|
4600
|
+
answers[q.name] = choice;
|
|
4601
|
+
continue;
|
|
3651
4602
|
}
|
|
3652
|
-
const
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
}
|
|
4603
|
+
const def = promptDefault(q, answers);
|
|
4604
|
+
const check = (v) => {
|
|
4605
|
+
const r2 = normalizeAnswer(q, v.trim() === "" ? def ?? "" : v);
|
|
4606
|
+
return "error" in r2 ? r2.error : void 0;
|
|
4607
|
+
};
|
|
4608
|
+
const raw = q.type === "password" ? await p.password({ message: q.message, validate: check }) : await p.text({
|
|
4609
|
+
message: q.message,
|
|
4610
|
+
...q.placeholder !== void 0 && { placeholder: q.placeholder },
|
|
4611
|
+
...def !== void 0 && { placeholder: def, defaultValue: def },
|
|
4612
|
+
validate: check
|
|
3661
4613
|
});
|
|
3662
|
-
if (p.isCancel(
|
|
3663
|
-
|
|
4614
|
+
if (p.isCancel(raw)) cancel2();
|
|
4615
|
+
const r = normalizeAnswer(q, raw.trim() === "" ? def ?? "" : raw);
|
|
4616
|
+
answers[q.name] = "value" in r ? r.value : "";
|
|
3664
4617
|
}
|
|
3665
|
-
const
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
...smtpHost !== void 0 && { smtpHost },
|
|
3682
|
-
...smtpPort !== void 0 && { smtpPort },
|
|
3683
|
-
...smtpUser !== void 0 && { smtpUser },
|
|
3684
|
-
...smtpPass !== void 0 && { smtpPass }
|
|
3685
|
-
};
|
|
4618
|
+
const env = envFromAnswers(prompts, answers);
|
|
4619
|
+
const opts = registryOptions(
|
|
4620
|
+
{
|
|
4621
|
+
language,
|
|
4622
|
+
database,
|
|
4623
|
+
modelApi: modelApi2 ?? "defineModel"
|
|
4624
|
+
},
|
|
4625
|
+
items,
|
|
4626
|
+
{
|
|
4627
|
+
projectName,
|
|
4628
|
+
authStrategy,
|
|
4629
|
+
cluster,
|
|
4630
|
+
taskBackend,
|
|
4631
|
+
...answers["provider"] === "custom" && { smtpProvider: "custom" }
|
|
4632
|
+
}
|
|
4633
|
+
);
|
|
3686
4634
|
const spinner2 = p.spinner();
|
|
3687
4635
|
spinner2.start("Scaffolding project\u2026");
|
|
3688
4636
|
try {
|
|
3689
4637
|
await scaffold(opts);
|
|
4638
|
+
if (Object.keys(env).length) {
|
|
4639
|
+
await setEnvValues(path3.resolve(process.cwd(), opts.projectName, ".env"), env);
|
|
4640
|
+
}
|
|
4641
|
+
await writeManifest(path3.resolve(process.cwd(), opts.projectName), opts, items);
|
|
3690
4642
|
spinner2.stop("Project created");
|
|
3691
4643
|
} catch (err) {
|
|
3692
4644
|
spinner2.stop("Failed to scaffold project");
|
|
@@ -3700,11 +4652,12 @@ async function main() {
|
|
|
3700
4652
|
await npmInstallGlobal().catch(() => {
|
|
3701
4653
|
});
|
|
3702
4654
|
spinner2.stop("efc CLI ready");
|
|
3703
|
-
|
|
4655
|
+
const secrets = Object.keys(env).filter((k) => /PASS|SECRET|PRIVATE_KEY/.test(k));
|
|
4656
|
+
if (secrets.length) {
|
|
3704
4657
|
p.note(
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
"
|
|
4658
|
+
`${secrets.join(", ")} ${secrets.length > 1 ? "were" : "was"} written to ${projectName}/.env \u2014 that file is gitignored, never commit it.
|
|
4659
|
+
Rotate any of them immediately if it is ever exposed.`,
|
|
4660
|
+
"Credentials"
|
|
3708
4661
|
);
|
|
3709
4662
|
}
|
|
3710
4663
|
p.outro(
|
|
@@ -3715,6 +4668,8 @@ Your project is ready!
|
|
|
3715
4668
|
`) + pc.dim(` efc start dev
|
|
3716
4669
|
`) + pc.dim(`
|
|
3717
4670
|
(or: npm run dev)
|
|
4671
|
+
`) + pc.dim(`
|
|
4672
|
+
Add features any time: efc list \xB7 efc add <item>
|
|
3718
4673
|
`)
|
|
3719
4674
|
);
|
|
3720
4675
|
}
|
|
@@ -3727,7 +4682,10 @@ function npmInstall(projectDir) {
|
|
|
3727
4682
|
function npmInstallGlobal() {
|
|
3728
4683
|
return new Promise((resolve, reject) => {
|
|
3729
4684
|
const child = spawn("npm", ["install", "-g", "express-file-cluster"], { stdio: "ignore" });
|
|
3730
|
-
child.on(
|
|
4685
|
+
child.on(
|
|
4686
|
+
"exit",
|
|
4687
|
+
(code) => code === 0 ? resolve() : reject(new Error("global install failed"))
|
|
4688
|
+
);
|
|
3731
4689
|
});
|
|
3732
4690
|
}
|
|
3733
4691
|
main().catch((err) => {
|