create-efc-app 0.9.2 → 0.9.6

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 CHANGED
@@ -153,7 +153,7 @@ async function scaffold(opts) {
153
153
  }
154
154
  async function writePackageJson(dest, opts) {
155
155
  const deps = {
156
- "express-file-cluster": "^0.9.2"
156
+ "express-file-cluster": "^0.9.6"
157
157
  };
158
158
  if (opts.database === "mongodb") deps["mongoose"] = "^9.7.2";
159
159
  if (opts.database === "postgresql") {
@@ -592,9 +592,9 @@ ${hasUser ? `import { User } from '../../model/User.js';
592
592
  const match = await verifyPassword(password, admin.password);
593
593
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
594
594
  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' });
595
+ const token = await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
596
+ const refreshToken = await issueRefreshToken(res, Admin, admin.id);
597
+ return res.json({ message: 'Logged in as admin', ...(token ? { token, refreshToken } : {}) });
598
598
  }
599
599
 
600
600
  const user = await ${db.findOne("User", `{ email }`)};
@@ -602,23 +602,26 @@ ${hasUser ? `import { User } from '../../model/User.js';
602
602
  const match = await verifyPassword(password, user.password);
603
603
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
604
604
  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
- res.json({ message: 'Logged in' });` : hasAdmin ? ` const admin = await ${db.findOne("Admin", `{ email }`)};
605
+ const token = await issueToken(res, { id: user.id, role: user.role, email: user.email });
606
+ const refreshToken = await issueRefreshToken(res, User, user.id);
607
+ // bearer: the client stores both tokens itself; http-only: they travel as cookies.
608
+ res.json({ message: 'Logged in', ...(token ? { token, refreshToken } : {}) });` : hasAdmin ? ` const admin = await ${db.findOne("Admin", `{ email }`)};
608
609
  if (!admin) return res.status(401).json({ error: 'Invalid credentials' });
609
610
  const match = await verifyPassword(password, admin.password);
610
611
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
611
612
  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
- res.json({ message: 'Logged in as admin' });` : ` const user = await ${db.findOne("User", `{ email }`)};
613
+ const token = await issueToken(res, { id: admin.id, role: admin.role, email: admin.email });
614
+ const refreshToken = await issueRefreshToken(res, Admin, admin.id);
615
+ // bearer: the client stores both tokens itself; http-only: they travel as cookies.
616
+ res.json({ message: 'Logged in as admin', ...(token ? { token, refreshToken } : {}) });` : ` const user = await ${db.findOne("User", `{ email }`)};
615
617
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });
616
618
  const match = await verifyPassword(password, user.password);
617
619
  if (!match) return res.status(401).json({ error: 'Invalid credentials' });
618
620
  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
- res.json({ message: 'Logged in' });`;
621
+ const token = await issueToken(res, { id: user.id, role: user.role, email: user.email });
622
+ const refreshToken = await issueRefreshToken(res, User, user.id);
623
+ // bearer: the client stores both tokens itself; http-only: they travel as cookies.
624
+ res.json({ message: 'Logged in', ...(token ? { token, refreshToken } : {}) });`;
622
625
  const loginContent = hasModels ? ts ? `import { issueToken, verifyPassword } from 'express-file-cluster/auth';
623
626
  import type { Request, Response } from 'express';
624
627
  ${loginDbImports}${loginMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
@@ -627,7 +630,7 @@ async function issueRefreshToken(
627
630
  res: Response,
628
631
  model: ${usesTables(opts) ? "{ update: (args: { where: { id: string }; data: Record<string, unknown> }) => Promise<unknown> }" : "{ update: (id: string, data: Record<string, unknown>) => Promise<unknown> }"},
629
632
  id: string,
630
- ): Promise<void> {
633
+ ): Promise<string> {
631
634
  const refreshToken = crypto.randomBytes(40).toString('hex');
632
635
  await ${db.update("model", "id", "{ refreshToken, refreshTokenExpiry: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) }")};
633
636
  res.cookie('efc_refresh_token', refreshToken, {
@@ -636,11 +639,15 @@ async function issueRefreshToken(
636
639
  sameSite: 'strict',
637
640
  maxAge: REFRESH_TOKEN_TTL_MS,
638
641
  });
642
+ return refreshToken;
639
643
  }
640
644
 
641
645
  export const POST = async (req: Request, res: Response) => {
642
646
  const { email, password } = req.body;
643
- if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
647
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
648
+ if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
649
+ return res.status(400).json({ error: 'email and password are required' });
650
+ }
644
651
 
645
652
  ${loginBody}
646
653
  };
@@ -656,11 +663,15 @@ async function issueRefreshToken(res, model, id) {
656
663
  sameSite: 'strict',
657
664
  maxAge: REFRESH_TOKEN_TTL_MS,
658
665
  });
666
+ return refreshToken;
659
667
  }
660
668
 
661
669
  export const POST = async (req, res) => {
662
670
  const { email, password } = req.body;
663
- if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
671
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
672
+ if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
673
+ return res.status(400).json({ error: 'email and password are required' });
674
+ }
664
675
 
665
676
  ${loginBody}
666
677
  };
@@ -750,7 +761,8 @@ ${sendsMail(opts) ? "import { enqueue } from 'express-file-cluster/tasks';\n" :
750
761
  const registerContent = ts ? `import type { Request, Response } from 'express';
751
762
  ${registerDbImports}${registerMeta}export const POST = async (req: Request, res: Response) => {
752
763
  const { name, email, password } = req.body;
753
- if (!name || !email || !password) {
764
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
765
+ if (typeof name !== 'string' || typeof email !== 'string' || typeof password !== 'string' || !name || !email || !password) {
754
766
  return res.status(400).json({ error: 'name, email and password are required' });
755
767
  }
756
768
  const existing = await ${db.findOne("User", `{ email }`)};
@@ -759,12 +771,13 @@ ${registerDbImports}${registerMeta}export const POST = async (req: Request, res:
759
771
  const verifyToken = crypto.randomBytes(32).toString('hex');
760
772
  const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
761
773
  ${sendVerifyEmail}
762
- const { password: _, verifyToken: __, ...safe } = user;
774
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
763
775
  res.status(201).json({ message: 'Account created successfully', user: safe });
764
776
  };
765
777
  ` : `${registerDbImports}${registerMeta}export const POST = async (req, res) => {
766
778
  const { name, email, password } = req.body;
767
- if (!name || !email || !password) {
779
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
780
+ if (typeof name !== 'string' || typeof email !== 'string' || typeof password !== 'string' || !name || !email || !password) {
768
781
  return res.status(400).json({ error: 'name, email and password are required' });
769
782
  }
770
783
  const existing = await ${db.findOne("User", `{ email }`)};
@@ -773,7 +786,7 @@ ${sendVerifyEmail}
773
786
  const verifyToken = crypto.randomBytes(32).toString('hex');
774
787
  const user = await ${db.create("User", `{ name, email, password: hashed, verifyToken }`)};
775
788
  ${sendVerifyEmail}
776
- const { password: _, verifyToken: __, ...safe } = user;
789
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
777
790
  res.status(201).json({ message: 'Account created successfully', user: safe });
778
791
  };
779
792
  `;
@@ -925,18 +938,21 @@ export const GET = async (req: Request, res: Response) => {
925
938
  ${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
926
939
  const limit = Math.min(100, Number(req.query.limit) || 20);
927
940
  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);
941
+ const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...u }) => u);
929
942
  res.json({ users, total, page, limit });
930
943
  };
931
944
 
932
945
  export const POST = async (req: Request, res: Response) => {
933
946
  ${roleGuard} const { name, email, password, role } = req.body;
934
- if (!name || !email || !password) return res.status(400).json({ error: 'name, email and password are required' });
947
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
948
+ if (typeof name !== 'string' || typeof email !== 'string' || typeof password !== 'string' || !name || !email || !password) {
949
+ return res.status(400).json({ error: 'name, email and password are required' });
950
+ }
935
951
  const existing = await ${db.findOne("User", `{ email }`)};
936
952
  if (existing) return res.status(409).json({ error: 'Email already in use' });
937
953
  const hashed = await hashPassword(password);
938
954
  const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
939
- const { password: _, ...safe } = user;
955
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
940
956
  res.status(201).json({ message: 'User created', user: safe });
941
957
  };
942
958
  ` : `import { hashPassword, requireAuth } from 'express-file-cluster/auth';
@@ -945,18 +961,21 @@ export const GET = async (req, res) => {
945
961
  ${roleGuard} const page = Math.max(1, Number(req.query.page) || 1);
946
962
  const limit = Math.min(100, Number(req.query.limit) || 20);
947
963
  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);
964
+ const users = all.slice((page - 1) * limit, page * limit).map(({ password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...u }) => u);
949
965
  res.json({ users, total, page, limit });
950
966
  };
951
967
 
952
968
  export const POST = async (req, res) => {
953
969
  ${roleGuard} const { name, email, password, role } = req.body;
954
- if (!name || !email || !password) return res.status(400).json({ error: 'name, email and password are required' });
970
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
971
+ if (typeof name !== 'string' || typeof email !== 'string' || typeof password !== 'string' || !name || !email || !password) {
972
+ return res.status(400).json({ error: 'name, email and password are required' });
973
+ }
955
974
  const existing = await ${db.findOne("User", `{ email }`)};
956
975
  if (existing) return res.status(409).json({ error: 'Email already in use' });
957
976
  const hashed = await hashPassword(password);
958
977
  const user = await ${db.create("User", `{ name, email, password: hashed, role: role ?? 'user' }`)};
959
- const { password: _, ...safe } = user;
978
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
960
979
  res.status(201).json({ message: 'User created', user: safe });
961
980
  };
962
981
  `;
@@ -1005,14 +1024,14 @@ export const meta: RouteMeta = {
1005
1024
  ` : "";
1006
1025
  const adminUserByIdDbImport = `import { User } from '../../../model/User.js';
1007
1026
  `;
1008
- const userByIdContent = ts ? `import { requireAuth } from 'express-file-cluster/auth';
1027
+ const userByIdContent = ts ? `import { requireAuth, revokeAllSessions } from 'express-file-cluster/auth';
1009
1028
  import type { Request, Response } from 'express';
1010
1029
  ${adminUserByIdDbImport}${userByIdMeta}${middlewares}
1011
1030
  export const GET = async (req: Request, res: Response) => {
1012
1031
  ${roleGuard} const { id } = req.params;
1013
1032
  const user = await ${db.findById("User", `id`)};
1014
1033
  if (!user) return res.status(404).json({ error: 'User not found' });
1015
- const { password: _, ...safe } = user;
1034
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
1016
1035
  res.json({ user: safe });
1017
1036
  };
1018
1037
 
@@ -1021,7 +1040,10 @@ ${roleGuard} const { id } = req.params;
1021
1040
  const { name, email, role, isActive } = req.body;
1022
1041
  const updated = await ${db.update("User", `id`, `{ name, email, role, isActive }`)};
1023
1042
  if (!updated) return res.status(404).json({ error: 'User not found' });
1024
- const { password: _, ...safe } = updated;
1043
+ // The role lives in the user's JWTs: end them, or a demoted/suspended user keeps access
1044
+ // until they expire. (/auth/refresh also refuses inactive accounts.)
1045
+ if (role !== undefined || isActive === false) await revokeAllSessions(id);
1046
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
1025
1047
  res.json({ message: 'User updated', user: safe });
1026
1048
  };
1027
1049
 
@@ -1030,15 +1052,16 @@ ${roleGuard} const { id } = req.params;
1030
1052
  const user = await ${db.findById("User", `id`)};
1031
1053
  if (!user) return res.status(404).json({ error: 'User not found' });
1032
1054
  await ${db.delete("User", `id`)};
1055
+ await revokeAllSessions(id);
1033
1056
  res.json({ message: \`User \${id} deleted\` });
1034
1057
  };
1035
- ` : `import { requireAuth } from 'express-file-cluster/auth';
1058
+ ` : `import { requireAuth, revokeAllSessions } from 'express-file-cluster/auth';
1036
1059
  ${adminUserByIdDbImport}${userByIdMeta}${middlewares}
1037
1060
  export const GET = async (req, res) => {
1038
1061
  ${roleGuard} const { id } = req.params;
1039
1062
  const user = await ${db.findById("User", `id`)};
1040
1063
  if (!user) return res.status(404).json({ error: 'User not found' });
1041
- const { password: _, ...safe } = user;
1064
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
1042
1065
  res.json({ user: safe });
1043
1066
  };
1044
1067
 
@@ -1047,7 +1070,10 @@ ${roleGuard} const { id } = req.params;
1047
1070
  const { name, email, role, isActive } = req.body;
1048
1071
  const updated = await ${db.update("User", `id`, `{ name, email, role, isActive }`)};
1049
1072
  if (!updated) return res.status(404).json({ error: 'User not found' });
1050
- const { password: _, ...safe } = updated;
1073
+ // The role lives in the user's JWTs: end them, or a demoted/suspended user keeps access
1074
+ // until they expire. (/auth/refresh also refuses inactive accounts.)
1075
+ if (role !== undefined || isActive === false) await revokeAllSessions(id);
1076
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
1051
1077
  res.json({ message: 'User updated', user: safe });
1052
1078
  };
1053
1079
 
@@ -1056,6 +1082,7 @@ ${roleGuard} const { id } = req.params;
1056
1082
  const user = await ${db.findById("User", `id`)};
1057
1083
  if (!user) return res.status(404).json({ error: 'User not found' });
1058
1084
  await ${db.delete("User", `id`)};
1085
+ await revokeAllSessions(id);
1059
1086
  res.json({ message: \`User \${id} deleted\` });
1060
1087
  };
1061
1088
  `;
@@ -1107,7 +1134,7 @@ export const GET = async (req: Request, res: Response) => {
1107
1134
  const { id } = (req as any).user;
1108
1135
  const user = await ${db.findById("User", `id`)};
1109
1136
  if (!user) return res.status(404).json({ error: 'User not found' });
1110
- const { password: _, ...safe } = user;
1137
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
1111
1138
  res.json({ user: safe });
1112
1139
  };
1113
1140
 
@@ -1116,7 +1143,7 @@ export const PUT = async (req: Request, res: Response) => {
1116
1143
  const { name, email } = req.body;
1117
1144
  const updated = await ${db.update("User", `id`, `{ name, email }`)};
1118
1145
  if (!updated) return res.status(404).json({ error: 'User not found' });
1119
- const { password: _, ...safe } = updated;
1146
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
1120
1147
  res.json({ message: 'Profile updated', user: safe });
1121
1148
  };
1122
1149
  ` : `import { requireAuth } from 'express-file-cluster/auth';
@@ -1125,7 +1152,7 @@ export const GET = async (req, res) => {
1125
1152
  const { id } = req.user;
1126
1153
  const user = await ${db.findById("User", `id`)};
1127
1154
  if (!user) return res.status(404).json({ error: 'User not found' });
1128
- const { password: _, ...safe } = user;
1155
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = user;
1129
1156
  res.json({ user: safe });
1130
1157
  };
1131
1158
 
@@ -1134,7 +1161,7 @@ export const PUT = async (req, res) => {
1134
1161
  const { name, email } = req.body;
1135
1162
  const updated = await ${db.update("User", `id`, `{ name, email }`)};
1136
1163
  if (!updated) return res.status(404).json({ error: 'User not found' });
1137
- const { password: _, ...safe } = updated;
1164
+ const { password: _, verifyToken: _vt, resetToken: _rt, resetTokenExpiry: _re, refreshToken: _ft, refreshTokenExpiry: _fe, ...safe } = updated;
1138
1165
  res.json({ message: 'Profile updated', user: safe });
1139
1166
  };
1140
1167
  `;
@@ -1432,12 +1459,15 @@ ${refreshModelImports}${refreshMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60
1432
1459
  export const POST = async (req: Request, res: Response) => {
1433
1460
  const token = req.cookies?.['efc_refresh_token'] || req.body?.refreshToken;
1434
1461
  if (!token) return res.status(401).json({ error: 'Refresh token required' });
1462
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
1463
+ if (typeof token !== 'string') return res.status(400).json({ error: 'Invalid refresh token' });
1435
1464
 
1436
1465
  ${refreshLookup}
1437
1466
 
1438
1467
  if (!account || !account.refreshTokenExpiry || new Date(account.refreshTokenExpiry) < new Date()) {
1439
1468
  return res.status(401).json({ error: 'Invalid or expired refresh token' });
1440
1469
  }
1470
+ if (account.isActive === false) return res.status(403).json({ error: 'Account suspended' });
1441
1471
 
1442
1472
  const newRefreshToken = crypto.randomBytes(40).toString('hex');
1443
1473
  const refreshTokenExpiry = new Date(Date.now() + REFRESH_TOKEN_TTL_MS);
@@ -1450,8 +1480,12 @@ ${refreshPersist}
1450
1480
  maxAge: REFRESH_TOKEN_TTL_MS,
1451
1481
  });
1452
1482
 
1453
- await issueToken(res, { id: account.id, role: account.role, email: account.email });
1454
- res.json({ message: 'Token refreshed' });
1483
+ const accessToken = await issueToken(res, { id: account.id, role: account.role, email: account.email });
1484
+ // bearer: the client stores both tokens itself; http-only: they travel as cookies.
1485
+ res.json({
1486
+ message: 'Token refreshed',
1487
+ ...(accessToken ? { token: accessToken, refreshToken: newRefreshToken } : {}),
1488
+ });
1455
1489
  };
1456
1490
  ` : `import { issueToken } from 'express-file-cluster/auth';
1457
1491
  import crypto from 'node:crypto';
@@ -1460,12 +1494,15 @@ ${refreshModelImports}${refreshMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60
1460
1494
  export const POST = async (req, res) => {
1461
1495
  const token = req.cookies?.efc_refresh_token || req.body?.refreshToken;
1462
1496
  if (!token) return res.status(401).json({ error: 'Refresh token required' });
1497
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
1498
+ if (typeof token !== 'string') return res.status(400).json({ error: 'Invalid refresh token' });
1463
1499
 
1464
1500
  ${refreshLookup}
1465
1501
 
1466
1502
  if (!account || !account.refreshTokenExpiry || new Date(account.refreshTokenExpiry) < new Date()) {
1467
1503
  return res.status(401).json({ error: 'Invalid or expired refresh token' });
1468
1504
  }
1505
+ if (account.isActive === false) return res.status(403).json({ error: 'Account suspended' });
1469
1506
 
1470
1507
  const newRefreshToken = crypto.randomBytes(40).toString('hex');
1471
1508
  const refreshTokenExpiry = new Date(Date.now() + REFRESH_TOKEN_TTL_MS);
@@ -1478,8 +1515,12 @@ ${refreshPersist}
1478
1515
  maxAge: REFRESH_TOKEN_TTL_MS,
1479
1516
  });
1480
1517
 
1481
- await issueToken(res, { id: account.id, role: account.role, email: account.email });
1482
- res.json({ message: 'Token refreshed' });
1518
+ const accessToken = await issueToken(res, { id: account.id, role: account.role, email: account.email });
1519
+ // bearer: the client stores both tokens itself; http-only: they travel as cookies.
1520
+ res.json({
1521
+ message: 'Token refreshed',
1522
+ ...(accessToken ? { token: accessToken, refreshToken: newRefreshToken } : {}),
1523
+ });
1483
1524
  };
1484
1525
  `;
1485
1526
  await fs.outputFile(path.join(dest, "src", "api", "auth", `refresh.${ext}`), refreshContent);
@@ -1513,7 +1554,8 @@ ${veMeta}export const GET = async (req: Request, res: Response) => {
1513
1554
 
1514
1555
  export const POST = async (req: Request, res: Response) => {
1515
1556
  const { email } = req.body;
1516
- if (!email) return res.status(400).json({ error: 'email is required' });
1557
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
1558
+ if (typeof email !== 'string' || !email) return res.status(400).json({ error: 'email is required' });
1517
1559
 
1518
1560
  const user = await ${db.findOne("User", `{ email }`)};
1519
1561
  if (user && !user.isVerified) {
@@ -1538,7 +1580,8 @@ ${veMeta}export const GET = async (req, res) => {
1538
1580
 
1539
1581
  export const POST = async (req, res) => {
1540
1582
  const { email } = req.body;
1541
- if (!email) return res.status(400).json({ error: 'email is required' });
1583
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
1584
+ if (typeof email !== 'string' || !email) return res.status(400).json({ error: 'email is required' });
1542
1585
 
1543
1586
  const user = await ${db.findOne("User", `{ email }`)};
1544
1587
  if (user && !user.isVerified) {
@@ -1573,7 +1616,8 @@ ${mailerTaskImport}${fpModelImports}${fpMeta}const RESET_TOKEN_TTL_MS = 1000 * 6
1573
1616
 
1574
1617
  export const POST = async (req: Request, res: Response) => {
1575
1618
  const { email } = req.body;
1576
- if (!email) return res.status(400).json({ error: 'email is required' });
1619
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
1620
+ if (typeof email !== 'string' || !email) return res.status(400).json({ error: 'email is required' });
1577
1621
 
1578
1622
  ${fpLookup}
1579
1623
 
@@ -1592,7 +1636,8 @@ ${mailerTaskImport}${fpModelImports}${fpMeta}const RESET_TOKEN_TTL_MS = 1000 * 6
1592
1636
 
1593
1637
  export const POST = async (req, res) => {
1594
1638
  const { email } = req.body;
1595
- if (!email) return res.status(400).json({ error: 'email is required' });
1639
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
1640
+ if (typeof email !== 'string' || !email) return res.status(400).json({ error: 'email is required' });
1596
1641
 
1597
1642
  ${fpLookup}
1598
1643
 
@@ -1628,7 +1673,10 @@ ${hasAdmin ? `import { Admin } from '../../model/Admin.js';
1628
1673
  import { hashPassword, revokeAllSessions } from 'express-file-cluster/auth';
1629
1674
  ${rpModelImports}${rpMeta}export const POST = async (req: Request, res: Response) => {
1630
1675
  const { token, password } = req.body;
1631
- if (!token || !password) return res.status(400).json({ error: 'token and password are required' });
1676
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
1677
+ if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
1678
+ return res.status(400).json({ error: 'token and password are required' });
1679
+ }
1632
1680
 
1633
1681
  ${rpLookup}
1634
1682
 
@@ -1646,7 +1694,10 @@ ${rpPersist}
1646
1694
  ` : `import { hashPassword, revokeAllSessions } from 'express-file-cluster/auth';
1647
1695
  ${rpModelImports}${rpMeta}export const POST = async (req, res) => {
1648
1696
  const { token, password } = req.body;
1649
- if (!token || !password) return res.status(400).json({ error: 'token and password are required' });
1697
+ // Strings only: an object such as {"$ne": null} would be read as a filter operator and match any account.
1698
+ if (typeof token !== 'string' || typeof password !== 'string' || !token || !password) {
1699
+ return res.status(400).json({ error: 'token and password are required' });
1700
+ }
1650
1701
 
1651
1702
  ${rpLookup}
1652
1703
 
@@ -2821,14 +2872,14 @@ ${suspendMeta}${mwAdmin4}
2821
2872
  export const POST = async (req: Request, res: Response) => {
2822
2873
  ${roleGuard} const { id } = req.params;
2823
2874
  const { reason } = req.body;
2824
- // TODO: set user.isActive = false, log audit event
2875
+ // TODO: set user.isActive = false (then revokeAllSessions(id) so open sessions end), log audit event
2825
2876
  res.json({ message: \`User \${id} suspended\`, reason });
2826
2877
  };
2827
2878
  ` : `${RA}${suspendMeta}${mwAdmin4}
2828
2879
  export const POST = async (req, res) => {
2829
2880
  ${roleGuard} const { id } = req.params;
2830
2881
  const { reason } = req.body;
2831
- // TODO: set user.isActive = false, log audit event
2882
+ // TODO: set user.isActive = false (then revokeAllSessions(id) so open sessions end), log audit event
2832
2883
  res.json({ message: \`User \${id} suspended\`, reason });
2833
2884
  };
2834
2885
  `;
@@ -3664,7 +3715,7 @@ async function writeOAuth(dest, opts) {
3664
3715
  const T = (s) => ts ? s : "";
3665
3716
  const userField = usesTables(opts) && generatedModels(opts).has("User") ? "user" : "userId";
3666
3717
  const lib = `import crypto from 'node:crypto';
3667
- import { HttpError } from 'express-file-cluster';
3718
+ import { HttpError, cache } from 'express-file-cluster';
3668
3719
  import { hashPassword, issueToken } from 'express-file-cluster/auth';
3669
3720
  ${T("import type { Request, Response } from 'express';\n")}import { User } from '../model/User.js';
3670
3721
  import { OAuthAccount } from '../model/OAuthAccount.js';
@@ -3672,11 +3723,14 @@ import { OAuthAccount } from '../model/OAuthAccount.js';
3672
3723
  // Shared by the OAuth sign-in routes (src/api/auth/<provider>/).
3673
3724
 
3674
3725
  const STATE_TTL_MS = 10 * 60 * 1000; // a sign-in attempt has 10 minutes to come back
3726
+ const CODE_TTL_S = 60; // a bearer app has a minute to exchange its sign-in code
3675
3727
  ${T(`
3676
3728
  export interface OAuthState {
3677
3729
  state: string;
3678
3730
  nonce: string;
3679
3731
  verifier?: string;
3732
+ /** Bearer apps: the S256 challenge of a verifier only the app knows (app \u2194 this server). */
3733
+ appChallenge?: string;
3680
3734
  }
3681
3735
 
3682
3736
  export interface OAuthProfile {
@@ -3743,7 +3797,7 @@ export function takeState(
3743
3797
  * user with the same (provider-verified) email, else a new user. New users get a random
3744
3798
  * password \u2014 they can set a real one with the forgot-password flow.
3745
3799
  */
3746
- export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuthProfile")})${T(": Promise<void>")} {
3800
+ export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuthProfile")})${T(": Promise<string | undefined>")} {
3747
3801
  const key = \`\${profile.provider}:\${profile.providerId}\`;
3748
3802
  const account = await ${db.findOne("OAuthAccount", "{ key }")};
3749
3803
  let user = account ? await ${db.findById("User", `account.${userField}`)} : null;
@@ -3778,12 +3832,66 @@ export async function signInWithOAuth(res${T(": Response")}, profile${T(": OAuth
3778
3832
  }
3779
3833
  }
3780
3834
  if (!user.isActive) throw new HttpError(403, 'Account suspended');
3781
- await issueToken(res, { id: user.id, role: user.role, email: user.email });
3835
+ // http-only: sets the cookie, returns undefined. bearer: returns the token.
3836
+ return issueToken(res, { id: user.id, role: user.role, email: user.email });
3837
+ }
3838
+
3839
+ /**
3840
+ * Bearer apps start sign-in with \`?code_challenge=<base64url SHA-256 of a random verifier>\`
3841
+ * (PKCE, RFC 7636, between the app and this server). Only the app holding the verifier can
3842
+ * redeem the sign-in code \u2014 another app catching the myapp:// redirect can't.
3843
+ */
3844
+ export function appChallengeFrom(query${T(": Request['query']")})${T(": string | undefined")} {
3845
+ const challenge = query.code_challenge;
3846
+ if (challenge === undefined) return undefined;
3847
+ if (typeof challenge !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(challenge)) {
3848
+ throw new HttpError(400, 'code_challenge must be the base64url SHA-256 of your code_verifier');
3849
+ }
3850
+ return challenge;
3782
3851
  }
3783
3852
 
3784
- /** Where the browser goes once signed in. */
3785
- export function successRedirect()${T(": string")} {
3786
- return process.env.OAUTH_SUCCESS_REDIRECT || '/';
3853
+ /**
3854
+ * Where the browser goes once signed in. With authStrategy 'bearer' the JWT never goes in the
3855
+ * URL (history, screenshots, logs): it gets a one-time code, valid for a minute, which the app
3856
+ * that started the sign-in trades for the token at POST /auth/oauth/exchange.
3857
+ */
3858
+ export async function successRedirect(
3859
+ token${T("?: string")},
3860
+ appChallenge${T("?: string")},
3861
+ )${T(": Promise<string>")} {
3862
+ const to = process.env.OAUTH_SUCCESS_REDIRECT || '/';
3863
+ if (!token) return to;
3864
+ if (!appChallenge) {
3865
+ throw new HttpError(
3866
+ 400,
3867
+ 'Bearer sign-in needs a code_challenge \u2014 start at /auth/<provider>?code_challenge=\u2026',
3868
+ );
3869
+ }
3870
+ const code = randomToken();
3871
+ await cache.set(\`oauth:code:\${code}\`, { token, appChallenge }, { ttl: CODE_TTL_S });
3872
+ return \`\${to}#code=\${code}\`;
3873
+ }
3874
+
3875
+ /**
3876
+ * The token for a one-time sign-in code, given the verifier behind the challenge the sign-in
3877
+ * started with. Once: a second use (or after a minute) gets 400.
3878
+ */
3879
+ export async function exchangeCode(code${T(": unknown")}, verifier${T(": unknown")})${T(": Promise<string>")} {
3880
+ if (typeof code !== 'string' || !code) throw new HttpError(400, 'code is required');
3881
+ if (typeof verifier !== 'string' || !/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) {
3882
+ throw new HttpError(400, 'code_verifier is required (43-128 characters)');
3883
+ }
3884
+ const key = \`oauth:code:\${code}\`;
3885
+ const saved = await cache.get${T("<{ token: string; appChallenge: string }>")}(key);
3886
+ const expected = Buffer.from(saved?.appChallenge ?? '');
3887
+ const actual = Buffer.from(pkceChallenge(verifier));
3888
+ // A wrong verifier leaves the code alone, so it can't be used to burn someone's sign-in.
3889
+ if (!saved || expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) {
3890
+ throw new HttpError(400, 'Invalid or expired sign-in code');
3891
+ }
3892
+ // del is atomic: of two requests racing with the same code, only one removes it.
3893
+ if ((await cache.del(key)) !== 1) throw new HttpError(400, 'Invalid or expired sign-in code');
3894
+ return saved.token;
3787
3895
  }
3788
3896
  `;
3789
3897
  const reqRes = (r) => ts ? `(${r}: Request, res: Response)` : `(${r}, res)`;
@@ -3793,14 +3901,16 @@ export function successRedirect()${T(": string")} {
3793
3901
  };
3794
3902
 
3795
3903
  `;
3796
- const googleStart = `${typeImport}import { pkceChallenge, randomToken, rememberState } from '../../../lib/oauth.js';
3904
+ const googleStart = `${typeImport}import { appChallengeFrom, pkceChallenge, randomToken, rememberState } from '../../../lib/oauth.js';
3797
3905
  ${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
3906
  // (GOOGLE_REDIRECT_URI \u2014 register that exact URL in the Google Cloud console).
3799
- export const GET = async ${reqRes("_req")} => {
3907
+ // Bearer apps add ?code_challenge=\u2026 (see appChallengeFrom in src/lib/oauth).
3908
+ export const GET = async ${reqRes("req")} => {
3909
+ const appChallenge = appChallengeFrom(req.query);
3800
3910
  const state = randomToken();
3801
3911
  const nonce = randomToken();
3802
3912
  const verifier = randomToken();
3803
- rememberState(res, 'google', { state, nonce, verifier });
3913
+ rememberState(res, 'google', { state, nonce, verifier, ...(appChallenge ? { appChallenge } : {}) });
3804
3914
  const params = new URLSearchParams({
3805
3915
  client_id: process.env.GOOGLE_CLIENT_ID ?? '',
3806
3916
  redirect_uri: process.env.GOOGLE_REDIRECT_URI ?? '',
@@ -3849,23 +3959,25 @@ export const GET = async ${reqRes("req")} => {
3849
3959
  });
3850
3960
  if (payload.nonce !== saved.nonce) throw new HttpError(400, 'Invalid sign-in response');
3851
3961
 
3852
- await signInWithOAuth(res, {
3962
+ const token = await signInWithOAuth(res, {
3853
3963
  provider: 'google',
3854
3964
  providerId: String(payload.sub),
3855
3965
  email: ${ts ? "payload.email as string | undefined" : "payload.email"},
3856
3966
  emailVerified: payload.email_verified === true,
3857
3967
  name: ${ts ? "payload.name as string | undefined" : "payload.name"},
3858
3968
  });
3859
- res.redirect(successRedirect());
3969
+ res.redirect(await successRedirect(token, saved.appChallenge));
3860
3970
  };
3861
3971
  `;
3862
- const appleStart = `${typeImport}import { randomToken, rememberState } from '../../../lib/oauth.js';
3972
+ const appleStart = `${typeImport}import { appChallengeFrom, randomToken, rememberState } from '../../../lib/oauth.js';
3863
3973
  ${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
3974
  // register it on the Services ID; Apple only accepts https URLs on a verified domain).
3865
- export const GET = async ${reqRes("_req")} => {
3975
+ // Bearer apps add ?code_challenge=\u2026 (see appChallengeFrom in src/lib/oauth).
3976
+ export const GET = async ${reqRes("req")} => {
3977
+ const appChallenge = appChallengeFrom(req.query);
3866
3978
  const state = randomToken();
3867
3979
  const nonce = randomToken();
3868
- rememberState(res, 'apple', { state, nonce }, true);
3980
+ rememberState(res, 'apple', { state, nonce, ...(appChallenge ? { appChallenge } : {}) }, true);
3869
3981
  const params = new URLSearchParams({
3870
3982
  client_id: process.env.APPLE_CLIENT_ID ?? '',
3871
3983
  redirect_uri: process.env.APPLE_REDIRECT_URI ?? '',
@@ -3934,18 +4046,24 @@ export const POST = async ${reqRes("req")} => {
3934
4046
  name = undefined;
3935
4047
  }
3936
4048
 
3937
- await signInWithOAuth(res, {
4049
+ const token = await signInWithOAuth(res, {
3938
4050
  provider: 'apple',
3939
4051
  providerId: String(payload.sub),
3940
4052
  email: ${ts ? "payload.email as string | undefined" : "payload.email"},
3941
4053
  emailVerified: payload.email_verified === true || payload.email_verified === 'true',
3942
4054
  name,
3943
4055
  });
3944
- res.redirect(303, successRedirect());
4056
+ res.redirect(303, await successRedirect(token, saved.appChallenge));
4057
+ };
4058
+ `;
4059
+ const exchange = `${typeImport}import { exchangeCode } from '../../../lib/oauth.js';
4060
+ ${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")} => {
4061
+ res.json({ token: await exchangeCode(req.body?.code, req.body?.code_verifier) });
3945
4062
  };
3946
4063
  `;
3947
4064
  const oauth = opts.oauth ?? {};
3948
4065
  await fs.outputFile(path.join(dest, "src", "lib", `oauth.${ext}`), lib);
4066
+ await fs.outputFile(path.join(dest, "src", "api", "auth", "oauth", `exchange.${ext}`), exchange);
3949
4067
  if (oauth.google) {
3950
4068
  await fs.outputFile(
3951
4069
  path.join(dest, "src", "api", "auth", "google", `index.${ext}`),
@@ -4001,6 +4119,18 @@ var admin = (flag) => (o) => {
4001
4119
  o.adminPortal = true;
4002
4120
  o.adminFeatures = { ...o.adminFeatures, [flag]: true };
4003
4121
  };
4122
+ var AUTH_STRATEGY_OPTIONS = [
4123
+ {
4124
+ value: "http-only",
4125
+ label: "http-only cookie",
4126
+ hint: "browsers \u2014 websites and SPAs (recommended)"
4127
+ },
4128
+ {
4129
+ value: "bearer",
4130
+ label: "Bearer token",
4131
+ hint: "mobile/desktop apps, API clients \u2014 keep it in Keychain/Keystore, not localStorage"
4132
+ }
4133
+ ];
4004
4134
  var ITEMS = [
4005
4135
  {
4006
4136
  name: "auth",
@@ -4008,7 +4138,16 @@ var ITEMS = [
4008
4138
  description: "User & Admin models; register, login, logout and me routes",
4009
4139
  group: "auth",
4010
4140
  registryDependencies: [],
4011
- 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.",
4141
+ 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.",
4142
+ prompts: [
4143
+ {
4144
+ name: "authStrategy",
4145
+ config: "authStrategy",
4146
+ type: "select",
4147
+ message: "Authentication strategy:",
4148
+ options: AUTH_STRATEGY_OPTIONS
4149
+ }
4150
+ ],
4012
4151
  enable: (o) => {
4013
4152
  o.userPortal = true;
4014
4153
  }
@@ -4555,29 +4694,26 @@ async function main() {
4555
4694
  hint: item.description
4556
4695
  });
4557
4696
  }
4697
+ const auth = await p.select({
4698
+ message: "Authentication:",
4699
+ options: [
4700
+ ...AUTH_STRATEGY_OPTIONS,
4701
+ { value: "none", label: "None", hint: "add it later with `efc add auth`" }
4702
+ ]
4703
+ });
4704
+ if (p.isCancel(auth)) cancel2();
4558
4705
  const picked = await p.groupMultiselect({
4559
4706
  message: `Add features now? ${pc.dim("(space to toggle, enter to confirm \u2014 or add any later with `efc add <item>`)")}`,
4560
4707
  options: groups,
4708
+ initialValues: auth === "none" ? [] : ["auth"],
4561
4709
  required: false
4562
4710
  });
4563
4711
  if (p.isCancel(picked)) cancel2();
4564
4712
  const items = withDependencies(picked);
4565
- let authStrategy = "http-only";
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 = {};
4713
+ const answers = auth === "none" ? {} : { authStrategy: auth };
4578
4714
  const prompts = itemPrompts(items);
4579
4715
  for (const q of prompts) {
4580
- if (!promptApplies(q, answers)) continue;
4716
+ if (q.name in answers || !promptApplies(q, answers)) continue;
4581
4717
  if (q.note) p.note(q.note);
4582
4718
  if (q.type === "select") {
4583
4719
  const choice = await p.select({
@@ -4617,7 +4753,7 @@ async function main() {
4617
4753
  items,
4618
4754
  {
4619
4755
  projectName,
4620
- authStrategy,
4756
+ authStrategy: answers["authStrategy"] ?? "http-only",
4621
4757
  cluster,
4622
4758
  taskBackend,
4623
4759
  ...answers["provider"] === "custom" && { smtpProvider: "custom" }
@@ -4652,6 +4788,17 @@ Rotate any of them immediately if it is ever exposed.`,
4652
4788
  "Credentials"
4653
4789
  );
4654
4790
  }
4791
+ if (opts.database === "postgresql") {
4792
+ const db = path3.basename(opts.projectName);
4793
+ p.note(
4794
+ `.env points DATABASE_URL at postgresql://localhost:5432/${db}, with no user or password.
4795
+ EFC creates the tables on first use, but not the database or the login:
4796
+ \u2022 Local server: createdb ${db} (add user:password@ to the URL if it asks for one)
4797
+ \u2022 Neon, Supabase, RDS: create the database in the dashboard and paste its full
4798
+ connection string into .env as DATABASE_URL`,
4799
+ "PostgreSQL"
4800
+ );
4801
+ }
4655
4802
  p.outro(
4656
4803
  pc.green(`
4657
4804
  Your project is ready!