create-efc-app 0.9.3 → 0.10.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/dist/index.js +316 -109
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -46,18 +46,22 @@ function sendsMail(opts) {
|
|
|
46
46
|
function usesTables(opts) {
|
|
47
47
|
return opts.database === "postgresql" && opts.modelApi !== "defineModel";
|
|
48
48
|
}
|
|
49
|
+
function usesCollections(opts) {
|
|
50
|
+
return opts.database === "mongodb" && opts.modelApi !== "defineModel";
|
|
51
|
+
}
|
|
49
52
|
function orm(opts) {
|
|
50
53
|
const t = usesTables(opts);
|
|
54
|
+
const c = usesCollections(opts);
|
|
51
55
|
const where = (filter) => filter.replace(/\s/g, "") === "{}" ? "" : `{ where: ${filter} }`;
|
|
52
56
|
const byId = (id) => id === "id" ? "{ id }" : `{ id: ${id} }`;
|
|
53
57
|
return {
|
|
54
58
|
findOne: (m, filter) => t ? `${m}.findFirst({ where: ${filter} })` : `${m}.findOne(${filter})`,
|
|
55
59
|
findById: (m, id) => t ? `${m}.findUnique({ where: ${byId(id)} })` : `${m}.findById(${id})`,
|
|
56
60
|
find: (m, filter) => t ? `${m}.findMany(${where(filter)})` : `${m}.find(${filter})`,
|
|
57
|
-
count: (m, filter) => t ? `${m}.count(${where(filter)})` : `${m}.count(${filter})`,
|
|
61
|
+
count: (m, filter) => t ? `${m}.count(${where(filter)})` : c ? `${m}.countDocuments(${filter})` : `${m}.count(${filter})`,
|
|
58
62
|
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})`
|
|
63
|
+
update: (m, id, data) => t ? `${m}.update({ where: ${byId(id)}, data: ${data} })` : c ? `${m}.findByIdAndUpdate(${id}, ${data}, { returnDocument: 'after' })` : `${m}.update(${id}, ${data})`,
|
|
64
|
+
delete: (m, id) => t ? `${m}.delete({ where: ${byId(id)} })` : c ? `${m}.findByIdAndDelete(${id})` : `${m}.delete(${id})`
|
|
61
65
|
};
|
|
62
66
|
}
|
|
63
67
|
function generatedModels(opts) {
|
|
@@ -83,7 +87,7 @@ function generatedModels(opts) {
|
|
|
83
87
|
return m;
|
|
84
88
|
}
|
|
85
89
|
function modelApi(opts) {
|
|
86
|
-
const name = usesTables(opts) ? "defineTable" : "defineModel";
|
|
90
|
+
const name = usesTables(opts) ? "defineTable" : usesCollections(opts) ? "defineCollection" : "defineModel";
|
|
87
91
|
return { name, import: `import { ${name} } from 'express-file-cluster';` };
|
|
88
92
|
}
|
|
89
93
|
function refField(opts, r) {
|
|
@@ -153,7 +157,7 @@ async function scaffold(opts) {
|
|
|
153
157
|
}
|
|
154
158
|
async function writePackageJson(dest, opts) {
|
|
155
159
|
const deps = {
|
|
156
|
-
"express-file-cluster": "^0.
|
|
160
|
+
"express-file-cluster": "^0.10.0"
|
|
157
161
|
};
|
|
158
162
|
if (opts.database === "mongodb") deps["mongoose"] = "^9.7.2";
|
|
159
163
|
if (opts.database === "postgresql") {
|
|
@@ -238,6 +242,9 @@ ${declaration}
|
|
|
238
242
|
jwtExpiresIn: process.env.JWT_EXPIRES_IN,
|
|
239
243
|
cookieDomain: process.env.COOKIE_DOMAIN,
|
|
240
244
|
cors: corsOrigins ? { origin: corsOrigins } : true,
|
|
245
|
+
// Behind a proxy / load balancer (Render, Fly, Heroku, nginx): the number of hops, e.g. 1,
|
|
246
|
+
// so req.ip is the client and rate limits count per client.
|
|
247
|
+
trustProxy: process.env.TRUST_PROXY ? Number(process.env.TRUST_PROXY) : undefined,
|
|
241
248
|
authStrategy: '${opts.authStrategy}',
|
|
242
249
|
tasks: ${tasks},
|
|
243
250
|
// Cache + session-revocation store (logout really invalidates tokens). Redis when
|
|
@@ -311,7 +318,8 @@ Optional \u2014 the same docs over MCP, with search and scaffolding tools:
|
|
|
311
318
|
${usesTables(opts) ? `- \`src/model/*.${ext}\` \u2014 \`defineTable\` models (PostgreSQL-native, Prisma-style queries:
|
|
312
319
|
\`findMany({ where, include })\`, \`create({ data })\`, \`update({ where, data })\`). A \`ref\` is a
|
|
313
320
|
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
|
|
314
|
-
them all${opts.language === "typescript" ? " and declares `Tables` (typed `include`)" : ""} \u2014 add new models there.` : `- \`src/model/*.${ext}\` \u2014 \`
|
|
321
|
+
them all${opts.language === "typescript" ? " and declares `Tables` (typed `include`)" : ""} \u2014 add new models there.` : usesCollections(opts) ? `- \`src/model/*.${ext}\` \u2014 \`defineCollection\` models (MongoDB-native, mongoose-style queries:
|
|
322
|
+
\`find(filter, { projection, sort })\`, \`findByIdAndUpdate\`, typed \`pipeline()\`, \`lookup()\`).` : `- \`src/model/*.${ext}\` \u2014 \`defineModel\` models.`}
|
|
315
323
|
- \`src/index.${ext}\` is the entry point; it imports \`efc.config.${ext}\` and passes it to \`ignite()\`.
|
|
316
324
|
- Only three import paths exist: \`express-file-cluster\`, \`express-file-cluster/auth\`,
|
|
317
325
|
\`express-file-cluster/tasks\`.
|
|
@@ -397,7 +405,9 @@ CORS_ORIGINS=http://localhost:3000${smtpVars}${oauthEnv}`;
|
|
|
397
405
|
NODE_ENV=development
|
|
398
406
|
DATABASE_URL=${dbExampleUrl}
|
|
399
407
|
JWT_SECRET=<generate with: openssl rand -hex 64>${redis}
|
|
400
|
-
CORS_ORIGINS=http://localhost:3000,https://yourapp.com
|
|
408
|
+
CORS_ORIGINS=http://localhost:3000,https://yourapp.com
|
|
409
|
+
# Behind a proxy / load balancer: how many hops to trust, so rate limits see client IPs
|
|
410
|
+
# TRUST_PROXY=1${smtpExample}${oauthExample}`;
|
|
401
411
|
await fs.outputFile(path.join(dest, ".env"), dotenv);
|
|
402
412
|
await fs.outputFile(path.join(dest, ".env.example"), example);
|
|
403
413
|
}
|
|
@@ -592,9 +602,9 @@ ${hasUser ? `import { User } from '../../model/User.js';
|
|
|
592
602
|
const match = await verifyPassword(password, admin.password);
|
|
593
603
|
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
|
594
604
|
if (!admin.isActive) return res.status(403).json({ error: 'Account suspended' });
|
|
595
|
-
await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
|
|
596
|
-
await issueRefreshToken(res, Admin, admin.id);
|
|
597
|
-
return res.json({ message: 'Logged in as admin' });
|
|
605
|
+
const token = await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
|
|
606
|
+
const refreshToken = await issueRefreshToken(res, Admin, admin.id);
|
|
607
|
+
return res.json({ message: 'Logged in as admin', ...(token ? { token, refreshToken } : {}) });
|
|
598
608
|
}
|
|
599
609
|
|
|
600
610
|
const user = await ${db.findOne("User", `{ email }`)};
|
|
@@ -602,32 +612,47 @@ ${hasUser ? `import { User } from '../../model/User.js';
|
|
|
602
612
|
const match = await verifyPassword(password, user.password);
|
|
603
613
|
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
|
604
614
|
if (!user.isActive) return res.status(403).json({ error: 'Account suspended' });
|
|
605
|
-
await issueToken(res, { id: user.id, role: user.role, email: user.email });
|
|
606
|
-
await issueRefreshToken(res, User, user.id);
|
|
607
|
-
|
|
615
|
+
const token = await issueToken(res, { id: user.id, role: user.role, email: user.email });
|
|
616
|
+
const refreshToken = await issueRefreshToken(res, User, user.id);
|
|
617
|
+
// bearer: the client stores both tokens itself; http-only: they travel as cookies.
|
|
618
|
+
res.json({ message: 'Logged in', ...(token ? { token, refreshToken } : {}) });` : hasAdmin ? ` const admin = await ${db.findOne("Admin", `{ email }`)};
|
|
608
619
|
if (!admin) return res.status(401).json({ error: 'Invalid credentials' });
|
|
609
620
|
const match = await verifyPassword(password, admin.password);
|
|
610
621
|
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
|
611
622
|
if (!admin.isActive) return res.status(403).json({ error: 'Account suspended' });
|
|
612
|
-
await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
|
|
613
|
-
await issueRefreshToken(res, Admin, admin.id);
|
|
614
|
-
|
|
623
|
+
const token = await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
|
|
624
|
+
const refreshToken = await issueRefreshToken(res, Admin, admin.id);
|
|
625
|
+
// bearer: the client stores both tokens itself; http-only: they travel as cookies.
|
|
626
|
+
res.json({ message: 'Logged in as admin', ...(token ? { token, refreshToken } : {}) });` : ` const user = await ${db.findOne("User", `{ email }`)};
|
|
615
627
|
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
|
616
628
|
const match = await verifyPassword(password, user.password);
|
|
617
629
|
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
|
|
618
630
|
if (!user.isActive) return res.status(403).json({ error: 'Account suspended' });
|
|
619
|
-
await issueToken(res, { id: user.id, role: user.role, email: user.email });
|
|
620
|
-
await issueRefreshToken(res, User, user.id);
|
|
621
|
-
|
|
631
|
+
const token = await issueToken(res, { id: user.id, role: user.role, email: user.email });
|
|
632
|
+
const refreshToken = await issueRefreshToken(res, User, user.id);
|
|
633
|
+
// bearer: the client stores both tokens itself; http-only: they travel as cookies.
|
|
634
|
+
res.json({ message: 'Logged in', ...(token ? { token, refreshToken } : {}) });`;
|
|
622
635
|
const loginContent = hasModels ? ts ? `import { issueToken, verifyPassword } from 'express-file-cluster/auth';
|
|
623
636
|
import type { Request, Response } from 'express';
|
|
624
|
-
|
|
637
|
+
import { rateLimit } from 'express-file-cluster';
|
|
638
|
+
${loginDbImports}${loginMeta}// 10 attempts per 15 minutes per address and account. Keyed on the email too, so behind a
|
|
639
|
+
// proxy without TRUST_PROXY (every client then has the proxy's IP) it still limits one
|
|
640
|
+
// account at a time, never everyone.
|
|
641
|
+
export const middlewares = [
|
|
642
|
+
rateLimit({
|
|
643
|
+
window: '15m',
|
|
644
|
+
max: 10,
|
|
645
|
+
key: (req) => \`\${req.ip}:\${String(req.body?.email ?? '').toLowerCase()}\`,
|
|
646
|
+
}),
|
|
647
|
+
];
|
|
648
|
+
|
|
649
|
+
const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
|
|
625
650
|
|
|
626
651
|
async function issueRefreshToken(
|
|
627
652
|
res: Response,
|
|
628
|
-
model: ${usesTables(opts) ? "{ update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown> }" : "{ update: (id: string, data: Record<string, unknown>) => Promise<unknown> }"},
|
|
653
|
+
model: ${usesTables(opts) ? "{ update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown> }" : usesCollections(opts) ? "{ findByIdAndUpdate: (id: string, data: Record<string, unknown>, options: { returnDocument: 'after' }) => Promise<unknown> }" : "{ update: (id: string, data: Record<string, unknown>) => Promise<unknown> }"},
|
|
629
654
|
id: string,
|
|
630
|
-
): Promise<
|
|
655
|
+
): Promise<string> {
|
|
631
656
|
const refreshToken = crypto.randomBytes(40).toString('hex');
|
|
632
657
|
await ${db.update("model", "id", "{ refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) }")};
|
|
633
658
|
res.cookie('efc_refresh_token', refreshToken, {
|
|
@@ -636,16 +661,32 @@ async function issueRefreshToken(
|
|
|
636
661
|
sameSite: 'strict',
|
|
637
662
|
maxAge: REFRESH_TOKEN_TTL_MS,
|
|
638
663
|
});
|
|
664
|
+
return refreshToken;
|
|
639
665
|
}
|
|
640
666
|
|
|
641
667
|
export const POST = async (req: Request, res: Response) => {
|
|
642
668
|
const { email, password } = req.body;
|
|
643
|
-
|
|
669
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
670
|
+
if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
|
|
671
|
+
return res.status(400).json({ error: 'email and password are required' });
|
|
672
|
+
}
|
|
644
673
|
|
|
645
674
|
${loginBody}
|
|
646
675
|
};
|
|
647
676
|
` : `import { issueToken, verifyPassword } from 'express-file-cluster/auth';
|
|
648
|
-
|
|
677
|
+
import { rateLimit } from 'express-file-cluster';
|
|
678
|
+
${loginDbImports}${loginMeta}// 10 attempts per 15 minutes per address and account. Keyed on the email too, so behind a
|
|
679
|
+
// proxy without TRUST_PROXY (every client then has the proxy's IP) it still limits one
|
|
680
|
+
// account at a time, never everyone.
|
|
681
|
+
export const middlewares = [
|
|
682
|
+
rateLimit({
|
|
683
|
+
window: '15m',
|
|
684
|
+
max: 10,
|
|
685
|
+
key: (req) => \`\${req.ip}:\${String(req.body?.email ?? '').toLowerCase()}\`,
|
|
686
|
+
}),
|
|
687
|
+
];
|
|
688
|
+
|
|
689
|
+
const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
|
|
649
690
|
|
|
650
691
|
async function issueRefreshToken(res, model, id) {
|
|
651
692
|
const refreshToken = crypto.randomBytes(40).toString('hex');
|
|
@@ -656,11 +697,15 @@ async function issueRefreshToken(res, model, id) {
|
|
|
656
697
|
sameSite: 'strict',
|
|
657
698
|
maxAge: REFRESH_TOKEN_TTL_MS,
|
|
658
699
|
});
|
|
700
|
+
return refreshToken;
|
|
659
701
|
}
|
|
660
702
|
|
|
661
703
|
export const POST = async (req, res) => {
|
|
662
704
|
const { email, password } = req.body;
|
|
663
|
-
|
|
705
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
706
|
+
if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
|
|
707
|
+
return res.status(400).json({ error: 'email and password are required' });
|
|
708
|
+
}
|
|
664
709
|
|
|
665
710
|
${loginBody}
|
|
666
711
|
};
|
|
@@ -750,7 +795,8 @@ ${sendsMail(opts) ? "import { enqueue } from 'express-file-cluster/tasks';\n" :
|
|
|
750
795
|
const registerContent = ts ? `import type { Request, Response } from 'express';
|
|
751
796
|
${registerDbImports}${registerMeta}export const POST = async (req: Request, res: Response) => {
|
|
752
797
|
const { name, email, password } = req.body;
|
|
753
|
-
|
|
798
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
799
|
+
if (typeof name !== 'string' || typeof email !== 'string' || typeof password !== 'string' || !name || !email || !password) {
|
|
754
800
|
return res.status(400).json({ error: 'name, email and password are required' });
|
|
755
801
|
}
|
|
756
802
|
const existing = await ${db.findOne("User", `{ email }`)};
|
|
@@ -759,12 +805,13 @@ ${registerDbImports}${registerMeta}export const POST = async (req: Request, res:
|
|
|
759
805
|
const verifyToken = crypto.randomBytes(32).toString('hex');
|
|
760
806
|
const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
|
|
761
807
|
${sendVerifyEmail}
|
|
762
|
-
const { password: _, verifyToken:
|
|
808
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
763
809
|
res.status(201).json({ message: 'Account created successfully', user: safe });
|
|
764
810
|
};
|
|
765
811
|
` : `${registerDbImports}${registerMeta}export const POST = async (req, res) => {
|
|
766
812
|
const { name, email, password } = req.body;
|
|
767
|
-
|
|
813
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
814
|
+
if (typeof name !== 'string' || typeof email !== 'string' || typeof password !== 'string' || !name || !email || !password) {
|
|
768
815
|
return res.status(400).json({ error: 'name, email and password are required' });
|
|
769
816
|
}
|
|
770
817
|
const existing = await ${db.findOne("User", `{ email }`)};
|
|
@@ -773,7 +820,7 @@ ${sendVerifyEmail}
|
|
|
773
820
|
const verifyToken = crypto.randomBytes(32).toString('hex');
|
|
774
821
|
const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
|
|
775
822
|
${sendVerifyEmail}
|
|
776
|
-
const { password: _, verifyToken:
|
|
823
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
777
824
|
res.status(201).json({ message: 'Account created successfully', user: safe });
|
|
778
825
|
};
|
|
779
826
|
`;
|
|
@@ -925,18 +972,21 @@ export const GET = async (req: Request, res: Response) => {
|
|
|
925
972
|
${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
|
|
926
973
|
const limit = Math.min(100, Number(req.query.limit) || 20);
|
|
927
974
|
const [all, total] = await Promise.all([${db.find("User", `{}`)}, ${db.count("User", `{}`)}]);
|
|
928
|
-
const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, ...u }) => u);
|
|
975
|
+
const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...u }) => u);
|
|
929
976
|
res.json({ users, total, page, limit });
|
|
930
977
|
};
|
|
931
978
|
|
|
932
979
|
export const POST = async (req: Request, res: Response) => {
|
|
933
980
|
${roleGuard} const { name, email, password, role } = req.body;
|
|
934
|
-
|
|
981
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
982
|
+
if (typeof name !== 'string' || typeof email !== 'string' || typeof password !== 'string' || !name || !email || !password) {
|
|
983
|
+
return res.status(400).json({ error: 'name, email and password are required' });
|
|
984
|
+
}
|
|
935
985
|
const existing = await ${db.findOne("User", `{ email }`)};
|
|
936
986
|
if (existing) return res.status(409).json({ error: 'Email already in use' });
|
|
937
987
|
const hashed = await hashPassword(password);
|
|
938
988
|
const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
|
|
939
|
-
const { password: _, ...safe } = user;
|
|
989
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
940
990
|
res.status(201).json({ message: 'User created', user: safe });
|
|
941
991
|
};
|
|
942
992
|
` : `import { hashPassword, requireAuth } from 'express-file-cluster/auth';
|
|
@@ -945,18 +995,21 @@ export const GET = async (req, res) => {
|
|
|
945
995
|
${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
|
|
946
996
|
const limit = Math.min(100, Number(req.query.limit) || 20);
|
|
947
997
|
const [all, total] = await Promise.all([${db.find("User", `{}`)}, ${db.count("User", `{}`)}]);
|
|
948
|
-
const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, ...u }) => u);
|
|
998
|
+
const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...u }) => u);
|
|
949
999
|
res.json({ users, total, page, limit });
|
|
950
1000
|
};
|
|
951
1001
|
|
|
952
1002
|
export const POST = async (req, res) => {
|
|
953
1003
|
${roleGuard} const { name, email, password, role } = req.body;
|
|
954
|
-
|
|
1004
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1005
|
+
if (typeof name !== 'string' || typeof email !== 'string' || typeof password !== 'string' || !name || !email || !password) {
|
|
1006
|
+
return res.status(400).json({ error: 'name, email and password are required' });
|
|
1007
|
+
}
|
|
955
1008
|
const existing = await ${db.findOne("User", `{ email }`)};
|
|
956
1009
|
if (existing) return res.status(409).json({ error: 'Email already in use' });
|
|
957
1010
|
const hashed = await hashPassword(password);
|
|
958
1011
|
const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
|
|
959
|
-
const { password: _, ...safe } = user;
|
|
1012
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
960
1013
|
res.status(201).json({ message: 'User created', user: safe });
|
|
961
1014
|
};
|
|
962
1015
|
`;
|
|
@@ -1005,14 +1058,14 @@ export const meta: RouteMeta = {
|
|
|
1005
1058
|
` : "";
|
|
1006
1059
|
const adminUserByIdDbImport = `import { User } from '../../../model/User.js';
|
|
1007
1060
|
`;
|
|
1008
|
-
const userByIdContent = ts ? `import { requireAuth } from 'express-file-cluster/auth';
|
|
1061
|
+
const userByIdContent = ts ? `import { requireAuth, revokeAllSessions } from 'express-file-cluster/auth';
|
|
1009
1062
|
import type { Request, Response } from 'express';
|
|
1010
1063
|
${adminUserByIdDbImport}${userByIdMeta}${middlewares}
|
|
1011
1064
|
export const GET = async (req: Request, res: Response) => {
|
|
1012
1065
|
${roleGuard} const { id } = req.params;
|
|
1013
1066
|
const user = await ${db.findById("User", `id`)};
|
|
1014
1067
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1015
|
-
const { password: _, ...safe } = user;
|
|
1068
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
1016
1069
|
res.json({ user: safe });
|
|
1017
1070
|
};
|
|
1018
1071
|
|
|
@@ -1021,7 +1074,10 @@ ${roleGuard} const { id } = req.params;
|
|
|
1021
1074
|
const { name, email, role, isActive } = req.body;
|
|
1022
1075
|
const updated = await ${db.update("User", `id`, `{ name, email, role, isActive }`)};
|
|
1023
1076
|
if (!updated) return res.status(404).json({ error: 'User not found' });
|
|
1024
|
-
|
|
1077
|
+
// The role lives in the user's JWTs: end them, or a demoted/suspended user keeps access
|
|
1078
|
+
// until they expire. (/auth/refresh also refuses inactive accounts.)
|
|
1079
|
+
if (role !== undefined || isActive === false) await revokeAllSessions(id);
|
|
1080
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
|
|
1025
1081
|
res.json({ message: 'User updated', user: safe });
|
|
1026
1082
|
};
|
|
1027
1083
|
|
|
@@ -1030,15 +1086,16 @@ ${roleGuard} const { id } = req.params;
|
|
|
1030
1086
|
const user = await ${db.findById("User", `id`)};
|
|
1031
1087
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1032
1088
|
await ${db.delete("User", `id`)};
|
|
1089
|
+
await revokeAllSessions(id);
|
|
1033
1090
|
res.json({ message: \`User \${id} deleted\` });
|
|
1034
1091
|
};
|
|
1035
|
-
` : `import { requireAuth } from 'express-file-cluster/auth';
|
|
1092
|
+
` : `import { requireAuth, revokeAllSessions } from 'express-file-cluster/auth';
|
|
1036
1093
|
${adminUserByIdDbImport}${userByIdMeta}${middlewares}
|
|
1037
1094
|
export const GET = async (req, res) => {
|
|
1038
1095
|
${roleGuard} const { id } = req.params;
|
|
1039
1096
|
const user = await ${db.findById("User", `id`)};
|
|
1040
1097
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1041
|
-
const { password: _, ...safe } = user;
|
|
1098
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
1042
1099
|
res.json({ user: safe });
|
|
1043
1100
|
};
|
|
1044
1101
|
|
|
@@ -1047,7 +1104,10 @@ ${roleGuard} const { id } = req.params;
|
|
|
1047
1104
|
const { name, email, role, isActive } = req.body;
|
|
1048
1105
|
const updated = await ${db.update("User", `id`, `{ name, email, role, isActive }`)};
|
|
1049
1106
|
if (!updated) return res.status(404).json({ error: 'User not found' });
|
|
1050
|
-
|
|
1107
|
+
// The role lives in the user's JWTs: end them, or a demoted/suspended user keeps access
|
|
1108
|
+
// until they expire. (/auth/refresh also refuses inactive accounts.)
|
|
1109
|
+
if (role !== undefined || isActive === false) await revokeAllSessions(id);
|
|
1110
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
|
|
1051
1111
|
res.json({ message: 'User updated', user: safe });
|
|
1052
1112
|
};
|
|
1053
1113
|
|
|
@@ -1056,6 +1116,7 @@ ${roleGuard} const { id } = req.params;
|
|
|
1056
1116
|
const user = await ${db.findById("User", `id`)};
|
|
1057
1117
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1058
1118
|
await ${db.delete("User", `id`)};
|
|
1119
|
+
await revokeAllSessions(id);
|
|
1059
1120
|
res.json({ message: \`User \${id} deleted\` });
|
|
1060
1121
|
};
|
|
1061
1122
|
`;
|
|
@@ -1107,7 +1168,7 @@ export const GET = async (req: Request, res: Response) => {
|
|
|
1107
1168
|
const { id } = (req as any).user;
|
|
1108
1169
|
const user = await ${db.findById("User", `id`)};
|
|
1109
1170
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1110
|
-
const { password: _, ...safe } = user;
|
|
1171
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
1111
1172
|
res.json({ user: safe });
|
|
1112
1173
|
};
|
|
1113
1174
|
|
|
@@ -1116,7 +1177,7 @@ export const PUT = async (req: Request, res: Response) => {
|
|
|
1116
1177
|
const { name, email } = req.body;
|
|
1117
1178
|
const updated = await ${db.update("User", `id`, `{ name, email }`)};
|
|
1118
1179
|
if (!updated) return res.status(404).json({ error: 'User not found' });
|
|
1119
|
-
const { password: _, ...safe } = updated;
|
|
1180
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
|
|
1120
1181
|
res.json({ message: 'Profile updated', user: safe });
|
|
1121
1182
|
};
|
|
1122
1183
|
` : `import { requireAuth } from 'express-file-cluster/auth';
|
|
@@ -1125,7 +1186,7 @@ export const GET = async (req, res) => {
|
|
|
1125
1186
|
const { id } = req.user;
|
|
1126
1187
|
const user = await ${db.findById("User", `id`)};
|
|
1127
1188
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
1128
|
-
const { password: _, ...safe } = user;
|
|
1189
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
|
|
1129
1190
|
res.json({ user: safe });
|
|
1130
1191
|
};
|
|
1131
1192
|
|
|
@@ -1134,7 +1195,7 @@ export const PUT = async (req, res) => {
|
|
|
1134
1195
|
const { name, email } = req.body;
|
|
1135
1196
|
const updated = await ${db.update("User", `id`, `{ name, email }`)};
|
|
1136
1197
|
if (!updated) return res.status(404).json({ error: 'User not found' });
|
|
1137
|
-
const { password: _, ...safe } = updated;
|
|
1198
|
+
const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
|
|
1138
1199
|
res.json({ message: 'Profile updated', user: safe });
|
|
1139
1200
|
};
|
|
1140
1201
|
`;
|
|
@@ -1432,12 +1493,15 @@ ${refreshModelImports}${refreshMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60
|
|
|
1432
1493
|
export const POST = async (req: Request, res: Response) => {
|
|
1433
1494
|
const token = req.cookies?.['efc_refresh_token'] || req.body?.refreshToken;
|
|
1434
1495
|
if (!token) return res.status(401).json({ error: 'Refresh token required' });
|
|
1496
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1497
|
+
if (typeof token !== 'string') return res.status(400).json({ error: 'Invalid refresh token' });
|
|
1435
1498
|
|
|
1436
1499
|
${refreshLookup}
|
|
1437
1500
|
|
|
1438
1501
|
if (!account || !account.refreshTokenExpiry || new Date(account.refreshTokenExpiry) < new Date()) {
|
|
1439
1502
|
return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
|
1440
1503
|
}
|
|
1504
|
+
if (account.isActive === false) return res.status(403).json({ error: 'Account suspended' });
|
|
1441
1505
|
|
|
1442
1506
|
const newRefreshToken = crypto.randomBytes(40).toString('hex');
|
|
1443
1507
|
const refreshTokenExpiry = new Date(Date.now() + REFRESH_TOKEN_TTL_MS);
|
|
@@ -1450,8 +1514,12 @@ ${refreshPersist}
|
|
|
1450
1514
|
maxAge: REFRESH_TOKEN_TTL_MS,
|
|
1451
1515
|
});
|
|
1452
1516
|
|
|
1453
|
-
await issueToken(res, { id: account.id, role: account.role, email: account.email });
|
|
1454
|
-
|
|
1517
|
+
const accessToken = await issueToken(res, { id: account.id, role: account.role, email: account.email });
|
|
1518
|
+
// bearer: the client stores both tokens itself; http-only: they travel as cookies.
|
|
1519
|
+
res.json({
|
|
1520
|
+
message: 'Token refreshed',
|
|
1521
|
+
...(accessToken ? { token: accessToken, refreshToken: newRefreshToken } : {}),
|
|
1522
|
+
});
|
|
1455
1523
|
};
|
|
1456
1524
|
` : `import { issueToken } from 'express-file-cluster/auth';
|
|
1457
1525
|
import crypto from 'node:crypto';
|
|
@@ -1460,12 +1528,15 @@ ${refreshModelImports}${refreshMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60
|
|
|
1460
1528
|
export const POST = async (req, res) => {
|
|
1461
1529
|
const token = req.cookies?.efc_refresh_token || req.body?.refreshToken;
|
|
1462
1530
|
if (!token) return res.status(401).json({ error: 'Refresh token required' });
|
|
1531
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1532
|
+
if (typeof token !== 'string') return res.status(400).json({ error: 'Invalid refresh token' });
|
|
1463
1533
|
|
|
1464
1534
|
${refreshLookup}
|
|
1465
1535
|
|
|
1466
1536
|
if (!account || !account.refreshTokenExpiry || new Date(account.refreshTokenExpiry) < new Date()) {
|
|
1467
1537
|
return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
|
1468
1538
|
}
|
|
1539
|
+
if (account.isActive === false) return res.status(403).json({ error: 'Account suspended' });
|
|
1469
1540
|
|
|
1470
1541
|
const newRefreshToken = crypto.randomBytes(40).toString('hex');
|
|
1471
1542
|
const refreshTokenExpiry = new Date(Date.now() + REFRESH_TOKEN_TTL_MS);
|
|
@@ -1478,8 +1549,12 @@ ${refreshPersist}
|
|
|
1478
1549
|
maxAge: REFRESH_TOKEN_TTL_MS,
|
|
1479
1550
|
});
|
|
1480
1551
|
|
|
1481
|
-
await issueToken(res, { id: account.id, role: account.role, email: account.email });
|
|
1482
|
-
|
|
1552
|
+
const accessToken = await issueToken(res, { id: account.id, role: account.role, email: account.email });
|
|
1553
|
+
// bearer: the client stores both tokens itself; http-only: they travel as cookies.
|
|
1554
|
+
res.json({
|
|
1555
|
+
message: 'Token refreshed',
|
|
1556
|
+
...(accessToken ? { token: accessToken, refreshToken: newRefreshToken } : {}),
|
|
1557
|
+
});
|
|
1483
1558
|
};
|
|
1484
1559
|
`;
|
|
1485
1560
|
await fs.outputFile(path.join(dest, "src", "api", "auth", `refresh.${ext}`), refreshContent);
|
|
@@ -1513,7 +1588,8 @@ ${veMeta}export const GET = async (req: Request, res: Response) => {
|
|
|
1513
1588
|
|
|
1514
1589
|
export const POST = async (req: Request, res: Response) => {
|
|
1515
1590
|
const { email } = req.body;
|
|
1516
|
-
|
|
1591
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1592
|
+
if (typeof email !== 'string' || !email) return res.status(400).json({ error: 'email is required' });
|
|
1517
1593
|
|
|
1518
1594
|
const user = await ${db.findOne("User", `{ email }`)};
|
|
1519
1595
|
if (user && !user.isVerified) {
|
|
@@ -1538,7 +1614,8 @@ ${veMeta}export const GET = async (req, res) => {
|
|
|
1538
1614
|
|
|
1539
1615
|
export const POST = async (req, res) => {
|
|
1540
1616
|
const { email } = req.body;
|
|
1541
|
-
|
|
1617
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1618
|
+
if (typeof email !== 'string' || !email) return res.status(400).json({ error: 'email is required' });
|
|
1542
1619
|
|
|
1543
1620
|
const user = await ${db.findOne("User", `{ email }`)};
|
|
1544
1621
|
if (user && !user.isVerified) {
|
|
@@ -1569,11 +1646,18 @@ ${hasAdmin ? `import { Admin } from '../../model/Admin.js';
|
|
|
1569
1646
|
else if (admin) await ${db.update("Admin", `admin.id`, `{ resetToken, resetTokenExpiry }`)};` : ` await ${db.update("User", `user.id`, `{ resetToken, resetTokenExpiry }`)};`;
|
|
1570
1647
|
const fpContent = ts ? `import type { Request, Response } from 'express';
|
|
1571
1648
|
import crypto from 'node:crypto';
|
|
1572
|
-
|
|
1649
|
+
import { rateLimit } from 'express-file-cluster';
|
|
1650
|
+
${mailerTaskImport}${fpModelImports}${fpMeta}// 5 reset emails per hour per address \u2014 keeps the endpoint from mail-bombing someone.
|
|
1651
|
+
export const middlewares = [
|
|
1652
|
+
rateLimit({ window: '1h', max: 5, key: (req) => String(req.body?.email ?? '').toLowerCase() }),
|
|
1653
|
+
];
|
|
1654
|
+
|
|
1655
|
+
const RESET_TOKEN_TTL_MS = 1000 * 60 * 60; // 1 hour
|
|
1573
1656
|
|
|
1574
1657
|
export const POST = async (req: Request, res: Response) => {
|
|
1575
1658
|
const { email } = req.body;
|
|
1576
|
-
|
|
1659
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1660
|
+
if (typeof email !== 'string' || !email) return res.status(400).json({ error: 'email is required' });
|
|
1577
1661
|
|
|
1578
1662
|
${fpLookup}
|
|
1579
1663
|
|
|
@@ -1588,11 +1672,18 @@ ${sendResetEmail} }
|
|
|
1588
1672
|
res.json({ message: 'Reset email sent' });
|
|
1589
1673
|
};
|
|
1590
1674
|
` : `import crypto from 'node:crypto';
|
|
1591
|
-
|
|
1675
|
+
import { rateLimit } from 'express-file-cluster';
|
|
1676
|
+
${mailerTaskImport}${fpModelImports}${fpMeta}// 5 reset emails per hour per address \u2014 keeps the endpoint from mail-bombing someone.
|
|
1677
|
+
export const middlewares = [
|
|
1678
|
+
rateLimit({ window: '1h', max: 5, key: (req) => String(req.body?.email ?? '').toLowerCase() }),
|
|
1679
|
+
];
|
|
1680
|
+
|
|
1681
|
+
const RESET_TOKEN_TTL_MS = 1000 * 60 * 60; // 1 hour
|
|
1592
1682
|
|
|
1593
1683
|
export const POST = async (req, res) => {
|
|
1594
1684
|
const { email } = req.body;
|
|
1595
|
-
|
|
1685
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1686
|
+
if (typeof email !== 'string' || !email) return res.status(400).json({ error: 'email is required' });
|
|
1596
1687
|
|
|
1597
1688
|
${fpLookup}
|
|
1598
1689
|
|
|
@@ -1628,7 +1719,10 @@ ${hasAdmin ? `import { Admin } from '../../model/Admin.js';
|
|
|
1628
1719
|
import { hashPassword, revokeAllSessions } from 'express-file-cluster/auth';
|
|
1629
1720
|
${rpModelImports}${rpMeta}export const POST = async (req: Request, res: Response) => {
|
|
1630
1721
|
const { token, password } = req.body;
|
|
1631
|
-
|
|
1722
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1723
|
+
if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
|
|
1724
|
+
return res.status(400).json({ error: 'token and password are required' });
|
|
1725
|
+
}
|
|
1632
1726
|
|
|
1633
1727
|
${rpLookup}
|
|
1634
1728
|
|
|
@@ -1646,7 +1740,10 @@ ${rpPersist}
|
|
|
1646
1740
|
` : `import { hashPassword, revokeAllSessions } from 'express-file-cluster/auth';
|
|
1647
1741
|
${rpModelImports}${rpMeta}export const POST = async (req, res) => {
|
|
1648
1742
|
const { token, password } = req.body;
|
|
1649
|
-
|
|
1743
|
+
// Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
|
|
1744
|
+
if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
|
|
1745
|
+
return res.status(400).json({ error: 'token and password are required' });
|
|
1746
|
+
}
|
|
1650
1747
|
|
|
1651
1748
|
${rpLookup}
|
|
1652
1749
|
|
|
@@ -2821,14 +2918,14 @@ ${suspendMeta}${mwAdmin4}
|
|
|
2821
2918
|
export const POST = async (req: Request, res: Response) => {
|
|
2822
2919
|
${roleGuard} const { id } = req.params;
|
|
2823
2920
|
const { reason } = req.body;
|
|
2824
|
-
// TODO: set user.isActive = false, log audit event
|
|
2921
|
+
// TODO: set user.isActive = false (then revokeAllSessions(id) so open sessions end), log audit event
|
|
2825
2922
|
res.json({ message: \`User \${id} suspended\`, reason });
|
|
2826
2923
|
};
|
|
2827
2924
|
` : `${RA}${suspendMeta}${mwAdmin4}
|
|
2828
2925
|
export const POST = async (req, res) => {
|
|
2829
2926
|
${roleGuard} const { id } = req.params;
|
|
2830
2927
|
const { reason } = req.body;
|
|
2831
|
-
// TODO: set user.isActive = false, log audit event
|
|
2928
|
+
// TODO: set user.isActive = false (then revokeAllSessions(id) so open sessions end), log audit event
|
|
2832
2929
|
res.json({ message: \`User \${id} suspended\`, reason });
|
|
2833
2930
|
};
|
|
2834
2931
|
`;
|
|
@@ -3664,7 +3761,7 @@ async function writeOAuth(dest, opts) {
|
|
|
3664
3761
|
const T = (s) => ts ? s : "";
|
|
3665
3762
|
const userField = usesTables(opts) && generatedModels(opts).has("User") ? "user" : "userId";
|
|
3666
3763
|
const lib = `import crypto from 'node:crypto';
|
|
3667
|
-
import { HttpError } from 'express-file-cluster';
|
|
3764
|
+
import { HttpError, cache } from 'express-file-cluster';
|
|
3668
3765
|
import { hashPassword, issueToken } from 'express-file-cluster/auth';
|
|
3669
3766
|
${T("import type { Request, Response } from 'express';\n")}import { User } from '../model/User.js';
|
|
3670
3767
|
import { OAuthAccount } from '../model/OAuthAccount.js';
|
|
@@ -3672,11 +3769,14 @@ import { OAuthAccount } from '../model/OAuthAccount.js';
|
|
|
3672
3769
|
// Shared by the OAuth sign-in routes (src/api/auth/<provider>/).
|
|
3673
3770
|
|
|
3674
3771
|
const STATE_TTL_MS = 10 * 60 * 1000; // a sign-in attempt has 10 minutes to come back
|
|
3772
|
+
const CODE_TTL_S = 60; // a bearer app has a minute to exchange its sign-in code
|
|
3675
3773
|
${T(`
|
|
3676
3774
|
export interface OAuthState {
|
|
3677
3775
|
state: string;
|
|
3678
3776
|
nonce: string;
|
|
3679
3777
|
verifier?: string;
|
|
3778
|
+
/** Bearer apps: the S256 challenge of a verifier only the app knows (app \u2194 this server). */
|
|
3779
|
+
appChallenge?: string;
|
|
3680
3780
|
}
|
|
3681
3781
|
|
|
3682
3782
|
export interface OAuthProfile {
|
|
@@ -3743,7 +3843,7 @@ export function takeState(
|
|
|
3743
3843
|
* user with the same (provider-verified) email, else a new user. New users get a random
|
|
3744
3844
|
* password \u2014 they can set a real one with the forgot-password flow.
|
|
3745
3845
|
*/
|
|
3746
|
-
export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuthProfile")})${T(": Promise<
|
|
3846
|
+
export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuthProfile")})${T(": Promise<string | undefined>")} {
|
|
3747
3847
|
const key = \`\${profile.provider}:\${profile.providerId}\`;
|
|
3748
3848
|
const account = await ${db.findOne("OAuthAccount", "{ key }")};
|
|
3749
3849
|
let user = account ? await ${db.findById("User", `account.${userField}`)} : null;
|
|
@@ -3778,12 +3878,66 @@ export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuth
|
|
|
3778
3878
|
}
|
|
3779
3879
|
}
|
|
3780
3880
|
if (!user.isActive) throw new HttpError(403, 'Account suspended');
|
|
3781
|
-
|
|
3881
|
+
// http-only: sets the cookie, returns undefined. bearer: returns the token.
|
|
3882
|
+
return issueToken(res, { id: user.id, role: user.role, email: user.email });
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
/**
|
|
3886
|
+
* Bearer apps start sign-in with \`?code_challenge=<base64url SHA-256 of a random verifier>\`
|
|
3887
|
+
* (PKCE, RFC 7636, between the app and this server). Only the app holding the verifier can
|
|
3888
|
+
* redeem the sign-in code \u2014 another app catching the myapp:// redirect can't.
|
|
3889
|
+
*/
|
|
3890
|
+
export function appChallengeFrom(query${T(": Request['query']")})${T(": string | undefined")} {
|
|
3891
|
+
const challenge = query.code_challenge;
|
|
3892
|
+
if (challenge === undefined) return undefined;
|
|
3893
|
+
if (typeof challenge !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(challenge)) {
|
|
3894
|
+
throw new HttpError(400, 'code_challenge must be the base64url SHA-256 of your code_verifier');
|
|
3895
|
+
}
|
|
3896
|
+
return challenge;
|
|
3782
3897
|
}
|
|
3783
3898
|
|
|
3784
|
-
/**
|
|
3785
|
-
|
|
3786
|
-
|
|
3899
|
+
/**
|
|
3900
|
+
* Where the browser goes once signed in. With authStrategy 'bearer' the JWT never goes in the
|
|
3901
|
+
* URL (history, screenshots, logs): it gets a one-time code, valid for a minute, which the app
|
|
3902
|
+
* that started the sign-in trades for the token at POST /auth/oauth/exchange.
|
|
3903
|
+
*/
|
|
3904
|
+
export async function successRedirect(
|
|
3905
|
+
token${T("?: string")},
|
|
3906
|
+
appChallenge${T("?: string")},
|
|
3907
|
+
)${T(": Promise<string>")} {
|
|
3908
|
+
const to = process.env.OAUTH_SUCCESS_REDIRECT || '/';
|
|
3909
|
+
if (!token) return to;
|
|
3910
|
+
if (!appChallenge) {
|
|
3911
|
+
throw new HttpError(
|
|
3912
|
+
400,
|
|
3913
|
+
'Bearer sign-in needs a code_challenge \u2014 start at /auth/<provider>?code_challenge=\u2026',
|
|
3914
|
+
);
|
|
3915
|
+
}
|
|
3916
|
+
const code = randomToken();
|
|
3917
|
+
await cache.set(\`oauth:code:\${code}\`, { token, appChallenge }, { ttl: CODE_TTL_S });
|
|
3918
|
+
return \`\${to}#code=\${code}\`;
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
/**
|
|
3922
|
+
* The token for a one-time sign-in code, given the verifier behind the challenge the sign-in
|
|
3923
|
+
* started with. Once: a second use (or after a minute) gets 400.
|
|
3924
|
+
*/
|
|
3925
|
+
export async function exchangeCode(code${T(": unknown")}, verifier${T(": unknown")})${T(": Promise<string>")} {
|
|
3926
|
+
if (typeof code !== 'string' || !code) throw new HttpError(400, 'code is required');
|
|
3927
|
+
if (typeof verifier !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) {
|
|
3928
|
+
throw new HttpError(400, 'code_verifier is required (43-128 characters)');
|
|
3929
|
+
}
|
|
3930
|
+
const key = \`oauth:code:\${code}\`;
|
|
3931
|
+
const saved = await cache.get${T("<{ token: string; appChallenge: string }>")}(key);
|
|
3932
|
+
const expected = Buffer.from(saved?.appChallenge ?? '');
|
|
3933
|
+
const actual = Buffer.from(pkceChallenge(verifier));
|
|
3934
|
+
// A wrong verifier leaves the code alone, so it can't be used to burn someone's sign-in.
|
|
3935
|
+
if (!saved || expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
|
|
3936
|
+
throw new HttpError(400, 'Invalid or expired sign-in code');
|
|
3937
|
+
}
|
|
3938
|
+
// del is atomic: of two requests racing with the same code, only one removes it.
|
|
3939
|
+
if ((await cache.del(key)) !== 1) throw new HttpError(400, 'Invalid or expired sign-in code');
|
|
3940
|
+
return saved.token;
|
|
3787
3941
|
}
|
|
3788
3942
|
`;
|
|
3789
3943
|
const reqRes = (r) => ts ? `(${r}: Request, res: Response)` : `(${r}, res)`;
|
|
@@ -3793,14 +3947,16 @@ export function successRedirect()${T(": string")} {
|
|
|
3793
3947
|
};
|
|
3794
3948
|
|
|
3795
3949
|
`;
|
|
3796
|
-
const googleStart = `${typeImport}import { pkceChallenge, randomToken, rememberState } from '../../../lib/oauth.js';
|
|
3950
|
+
const googleStart = `${typeImport}import { appChallengeFrom, pkceChallenge, randomToken, rememberState } from '../../../lib/oauth.js';
|
|
3797
3951
|
${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
|
|
3798
3952
|
// (GOOGLE_REDIRECT_URI \u2014 register that exact URL in the Google Cloud console).
|
|
3799
|
-
|
|
3953
|
+
// Bearer apps add ?code_challenge=\u2026 (see appChallengeFrom in src/lib/oauth).
|
|
3954
|
+
export const GET = async ${reqRes("req")} => {
|
|
3955
|
+
const appChallenge = appChallengeFrom(req.query);
|
|
3800
3956
|
const state = randomToken();
|
|
3801
3957
|
const nonce = randomToken();
|
|
3802
3958
|
const verifier = randomToken();
|
|
3803
|
-
rememberState(res, 'google', { state, nonce, verifier });
|
|
3959
|
+
rememberState(res, 'google', { state, nonce, verifier, ...(appChallenge ? { appChallenge } : {}) });
|
|
3804
3960
|
const params = new URLSearchParams({
|
|
3805
3961
|
client_id: process.env.GOOGLE_CLIENT_ID ?? '',
|
|
3806
3962
|
redirect_uri: process.env.GOOGLE_REDIRECT_URI ?? '',
|
|
@@ -3849,23 +4005,25 @@ export const GET = async ${reqRes("req")} => {
|
|
|
3849
4005
|
});
|
|
3850
4006
|
if (payload.nonce !== saved.nonce) throw new HttpError(400, 'Invalid sign-in response');
|
|
3851
4007
|
|
|
3852
|
-
await signInWithOAuth(res, {
|
|
4008
|
+
const token = await signInWithOAuth(res, {
|
|
3853
4009
|
provider: 'google',
|
|
3854
4010
|
providerId: String(payload.sub),
|
|
3855
4011
|
email: ${ts ? "payload.email as string | undefined" : "payload.email"},
|
|
3856
4012
|
emailVerified: payload.email_verified === true,
|
|
3857
4013
|
name: ${ts ? "payload.name as string | undefined" : "payload.name"},
|
|
3858
4014
|
});
|
|
3859
|
-
res.redirect(successRedirect());
|
|
4015
|
+
res.redirect(await successRedirect(token, saved.appChallenge));
|
|
3860
4016
|
};
|
|
3861
4017
|
`;
|
|
3862
|
-
const appleStart = `${typeImport}import { randomToken, rememberState } from '../../../lib/oauth.js';
|
|
4018
|
+
const appleStart = `${typeImport}import { appChallengeFrom, randomToken, rememberState } from '../../../lib/oauth.js';
|
|
3863
4019
|
${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
|
|
3864
4020
|
// register it on the Services ID; Apple only accepts https URLs on a verified domain).
|
|
3865
|
-
|
|
4021
|
+
// Bearer apps add ?code_challenge=\u2026 (see appChallengeFrom in src/lib/oauth).
|
|
4022
|
+
export const GET = async ${reqRes("req")} => {
|
|
4023
|
+
const appChallenge = appChallengeFrom(req.query);
|
|
3866
4024
|
const state = randomToken();
|
|
3867
4025
|
const nonce = randomToken();
|
|
3868
|
-
rememberState(res, 'apple', { state, nonce }, true);
|
|
4026
|
+
rememberState(res, 'apple', { state, nonce, ...(appChallenge ? { appChallenge } : {}) }, true);
|
|
3869
4027
|
const params = new URLSearchParams({
|
|
3870
4028
|
client_id: process.env.APPLE_CLIENT_ID ?? '',
|
|
3871
4029
|
redirect_uri: process.env.APPLE_REDIRECT_URI ?? '',
|
|
@@ -3934,18 +4092,24 @@ export const POST = async ${reqRes("req")} => {
|
|
|
3934
4092
|
name = undefined;
|
|
3935
4093
|
}
|
|
3936
4094
|
|
|
3937
|
-
await signInWithOAuth(res, {
|
|
4095
|
+
const token = await signInWithOAuth(res, {
|
|
3938
4096
|
provider: 'apple',
|
|
3939
4097
|
providerId: String(payload.sub),
|
|
3940
4098
|
email: ${ts ? "payload.email as string | undefined" : "payload.email"},
|
|
3941
4099
|
emailVerified: payload.email_verified === true || payload.email_verified === 'true',
|
|
3942
4100
|
name,
|
|
3943
4101
|
});
|
|
3944
|
-
res.redirect(303, successRedirect());
|
|
4102
|
+
res.redirect(303, await successRedirect(token, saved.appChallenge));
|
|
4103
|
+
};
|
|
4104
|
+
`;
|
|
4105
|
+
const exchange = `${typeImport}import { exchangeCode } from '../../../lib/oauth.js';
|
|
4106
|
+
${meta("POST", "Bearer apps: trade the one-time code from the sign-in redirect (plus the PKCE code_verifier) for a token.")}export const POST = async ${reqRes("req")} => {
|
|
4107
|
+
res.json({ token: await exchangeCode(req.body?.code, req.body?.code_verifier) });
|
|
3945
4108
|
};
|
|
3946
4109
|
`;
|
|
3947
4110
|
const oauth = opts.oauth ?? {};
|
|
3948
4111
|
await fs.outputFile(path.join(dest, "src", "lib", `oauth.${ext}`), lib);
|
|
4112
|
+
await fs.outputFile(path.join(dest, "src", "api", "auth", "oauth", `exchange.${ext}`), exchange);
|
|
3949
4113
|
if (oauth.google) {
|
|
3950
4114
|
await fs.outputFile(
|
|
3951
4115
|
path.join(dest, "src", "api", "auth", "google", `index.${ext}`),
|
|
@@ -3979,6 +4143,12 @@ var VARIANTS = ["typescript", "javascript"].flatMap((language) => {
|
|
|
3979
4143
|
database: "mongodb",
|
|
3980
4144
|
modelApi: "defineModel"
|
|
3981
4145
|
},
|
|
4146
|
+
{
|
|
4147
|
+
key: `${lang}-mongo-collection`,
|
|
4148
|
+
language,
|
|
4149
|
+
database: "mongodb",
|
|
4150
|
+
modelApi: "defineCollection"
|
|
4151
|
+
},
|
|
3982
4152
|
{
|
|
3983
4153
|
key: `${lang}-pg-model`,
|
|
3984
4154
|
language,
|
|
@@ -4001,6 +4171,18 @@ var admin = (flag) => (o) => {
|
|
|
4001
4171
|
o.adminPortal = true;
|
|
4002
4172
|
o.adminFeatures = { ...o.adminFeatures, [flag]: true };
|
|
4003
4173
|
};
|
|
4174
|
+
var AUTH_STRATEGY_OPTIONS = [
|
|
4175
|
+
{
|
|
4176
|
+
value: "http-only",
|
|
4177
|
+
label: "http-only cookie",
|
|
4178
|
+
hint: "browsers \u2014 websites and SPAs (recommended)"
|
|
4179
|
+
},
|
|
4180
|
+
{
|
|
4181
|
+
value: "bearer",
|
|
4182
|
+
label: "Bearer token",
|
|
4183
|
+
hint: "mobile/desktop apps, API clients \u2014 keep it in Keychain/Keystore, not localStorage"
|
|
4184
|
+
}
|
|
4185
|
+
];
|
|
4004
4186
|
var ITEMS = [
|
|
4005
4187
|
{
|
|
4006
4188
|
name: "auth",
|
|
@@ -4008,7 +4190,16 @@ var ITEMS = [
|
|
|
4008
4190
|
description: "User & Admin models; register, login, logout and me routes",
|
|
4009
4191
|
group: "auth",
|
|
4010
4192
|
registryDependencies: [],
|
|
4011
|
-
docs: "Tokens are issued per `authStrategy` in efc.config \u2014 'http-only' (cookie) or '
|
|
4193
|
+
docs: "Tokens are issued per `authStrategy` in efc.config \u2014 'http-only' (cookie) or 'bearer' (login returns the token; send it as Authorization: Bearer). Verification emails go through the SendEmail task; `efc add email-client` makes it send real mail.",
|
|
4194
|
+
prompts: [
|
|
4195
|
+
{
|
|
4196
|
+
name: "authStrategy",
|
|
4197
|
+
config: "authStrategy",
|
|
4198
|
+
type: "select",
|
|
4199
|
+
message: "Authentication strategy:",
|
|
4200
|
+
options: AUTH_STRATEGY_OPTIONS
|
|
4201
|
+
}
|
|
4202
|
+
],
|
|
4012
4203
|
enable: (o) => {
|
|
4013
4204
|
o.userPortal = true;
|
|
4014
4205
|
}
|
|
@@ -4373,7 +4564,7 @@ function registryOptions(v, items = [], extra = {}) {
|
|
|
4373
4564
|
projectName: "efc-app",
|
|
4374
4565
|
language: v.language,
|
|
4375
4566
|
database: v.database,
|
|
4376
|
-
|
|
4567
|
+
modelApi: v.modelApi,
|
|
4377
4568
|
authStrategy: "http-only",
|
|
4378
4569
|
cluster: true,
|
|
4379
4570
|
tasks: true,
|
|
@@ -4416,7 +4607,7 @@ async function writeManifest(dest, opts, items) {
|
|
|
4416
4607
|
registry: (pkg.dependencies["express-file-cluster"] ?? "").replace(/^[^\d]*/, ""),
|
|
4417
4608
|
language: opts.language,
|
|
4418
4609
|
database: opts.database,
|
|
4419
|
-
modelApi: opts.database === "postgresql" ? opts.modelApi ?? "defineTable" : "defineModel",
|
|
4610
|
+
modelApi: opts.database === "postgresql" ? opts.modelApi ?? "defineTable" : opts.modelApi === "defineModel" ? "defineModel" : "defineCollection",
|
|
4420
4611
|
tasks: opts.tasks ? opts.taskBackend ?? "bullmq" : null,
|
|
4421
4612
|
items: [...withDependencies(items)].sort(),
|
|
4422
4613
|
files: Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b)))
|
|
@@ -4512,22 +4703,30 @@ async function main() {
|
|
|
4512
4703
|
]
|
|
4513
4704
|
});
|
|
4514
4705
|
if (p.isCancel(database)) cancel2();
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4706
|
+
const api = await p.select({
|
|
4707
|
+
message: "Models:",
|
|
4708
|
+
options: database === "postgresql" ? [
|
|
4709
|
+
{
|
|
4710
|
+
value: "defineTable",
|
|
4711
|
+
label: "defineTable",
|
|
4712
|
+
hint: "PostgreSQL-native \u2014 foreign keys, relations, include, Prisma-style queries"
|
|
4713
|
+
},
|
|
4714
|
+
{ value: "defineModel", label: "defineModel", hint: "portable \u2014 the same API on MongoDB" }
|
|
4715
|
+
] : [
|
|
4716
|
+
{
|
|
4717
|
+
value: "defineCollection",
|
|
4718
|
+
label: "defineCollection",
|
|
4719
|
+
hint: "MongoDB-native \u2014 mongoose-style queries, typed pipelines, BSON types"
|
|
4720
|
+
},
|
|
4721
|
+
{
|
|
4722
|
+
value: "defineModel",
|
|
4723
|
+
label: "defineModel",
|
|
4724
|
+
hint: "portable \u2014 the same API on PostgreSQL"
|
|
4725
|
+
}
|
|
4726
|
+
]
|
|
4727
|
+
});
|
|
4728
|
+
if (p.isCancel(api)) cancel2();
|
|
4729
|
+
const modelApi2 = api;
|
|
4531
4730
|
const taskBackend = await p.select({
|
|
4532
4731
|
message: "Task queue backend:",
|
|
4533
4732
|
options: database === "postgresql" ? [
|
|
@@ -4555,29 +4754,26 @@ async function main() {
|
|
|
4555
4754
|
hint: item.description
|
|
4556
4755
|
});
|
|
4557
4756
|
}
|
|
4757
|
+
const auth = await p.select({
|
|
4758
|
+
message: "Authentication:",
|
|
4759
|
+
options: [
|
|
4760
|
+
...AUTH_STRATEGY_OPTIONS,
|
|
4761
|
+
{ value: "none", label: "None", hint: "add it later with `efc add auth`" }
|
|
4762
|
+
]
|
|
4763
|
+
});
|
|
4764
|
+
if (p.isCancel(auth)) cancel2();
|
|
4558
4765
|
const picked = await p.groupMultiselect({
|
|
4559
4766
|
message: `Add features now? ${pc.dim("(space to toggle, enter to confirm \u2014 or add any later with `efc add <item>`)")}`,
|
|
4560
4767
|
options: groups,
|
|
4768
|
+
initialValues: auth === "none" ? [] : ["auth"],
|
|
4561
4769
|
required: false
|
|
4562
4770
|
});
|
|
4563
4771
|
if (p.isCancel(picked)) cancel2();
|
|
4564
4772
|
const items = withDependencies(picked);
|
|
4565
|
-
|
|
4566
|
-
if (items.includes("auth")) {
|
|
4567
|
-
const strategy = await p.select({
|
|
4568
|
-
message: "Authentication strategy:",
|
|
4569
|
-
options: [
|
|
4570
|
-
{ value: "http-only", label: "http-only", hint: "secure cookie \u2014 recommended for SSR" },
|
|
4571
|
-
{ value: "localStorage", label: "localStorage", hint: "bearer token \u2014 for SPAs" }
|
|
4572
|
-
]
|
|
4573
|
-
});
|
|
4574
|
-
if (p.isCancel(strategy)) cancel2();
|
|
4575
|
-
authStrategy = strategy;
|
|
4576
|
-
}
|
|
4577
|
-
const answers = {};
|
|
4773
|
+
const answers = auth === "none" ? {} : { authStrategy: auth };
|
|
4578
4774
|
const prompts = itemPrompts(items);
|
|
4579
4775
|
for (const q of prompts) {
|
|
4580
|
-
if (!promptApplies(q, answers)) continue;
|
|
4776
|
+
if (q.name in answers || !promptApplies(q, answers)) continue;
|
|
4581
4777
|
if (q.note) p.note(q.note);
|
|
4582
4778
|
if (q.type === "select") {
|
|
4583
4779
|
const choice = await p.select({
|
|
@@ -4617,7 +4813,7 @@ async function main() {
|
|
|
4617
4813
|
items,
|
|
4618
4814
|
{
|
|
4619
4815
|
projectName,
|
|
4620
|
-
authStrategy,
|
|
4816
|
+
authStrategy: answers["authStrategy"] ?? "http-only",
|
|
4621
4817
|
cluster,
|
|
4622
4818
|
taskBackend,
|
|
4623
4819
|
...answers["provider"] === "custom" && { smtpProvider: "custom" }
|
|
@@ -4652,6 +4848,17 @@ Rotate any of them immediately if it is ever exposed.`,
|
|
|
4652
4848
|
"Credentials"
|
|
4653
4849
|
);
|
|
4654
4850
|
}
|
|
4851
|
+
if (opts.database === "postgresql") {
|
|
4852
|
+
const db = path3.basename(opts.projectName);
|
|
4853
|
+
p.note(
|
|
4854
|
+
`.env points DATABASE_URL at postgresql://localhost:5432/${db}, with no user or password.
|
|
4855
|
+
EFC creates the tables on first use, but not the database or the login:
|
|
4856
|
+
\u2022 Local server: createdb ${db} (add user:password@ to the URL if it asks for one)
|
|
4857
|
+
\u2022 Neon, Supabase, RDS: create the database in the dashboard and paste its full
|
|
4858
|
+
connection string into .env as DATABASE_URL`,
|
|
4859
|
+
"PostgreSQL"
|
|
4860
|
+
);
|
|
4861
|
+
}
|
|
4655
4862
|
p.outro(
|
|
4656
4863
|
pc.green(`
|
|
4657
4864
|
Your project is ready!
|