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 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.9.3"
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 \`defineModel\` models.`}
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${smtpExample}${oauthExample}`;
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
- res.json({ message: 'Logged in' });` : hasAdmin ? ` const admin = await ${db.findOne("Admin", `{ email }`)};
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
- res.json({ message: 'Logged in as admin' });` : ` const user = await ${db.findOne("User", `{ email }`)};
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
- res.json({ message: 'Logged in' });`;
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
- ${loginDbImports}${loginMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
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<void> {
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
- if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
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
- ${loginDbImports}${loginMeta}const REFRESH_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days
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
- if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
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
- if (!name || !email || !password) {
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: __, ...safe } = user;
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
- if (!name || !email || !password) {
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: __, ...safe } = user;
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
- if (!name || !email || !password) return res.status(400).json({ error: 'name, email and password are required' });
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
- if (!name || !email || !password) return res.status(400).json({ error: 'name, email and password are required' });
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
- const { password: _, ...safe } = updated;
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
- const { password: _, ...safe } = updated;
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
- res.json({ message: 'Token refreshed' });
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
- res.json({ message: 'Token refreshed' });
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
- if (!email) return res.status(400).json({ error: 'email is required' });
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
- if (!email) return res.status(400).json({ error: 'email is required' });
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
- ${mailerTaskImport}${fpModelImports}${fpMeta}const RESET_TOKEN_TTL_MS = 1000 * 60 * 60; // 1 hour
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
- if (!email) return res.status(400).json({ error: 'email is required' });
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
- ${mailerTaskImport}${fpModelImports}${fpMeta}const RESET_TOKEN_TTL_MS = 1000 * 60 * 60; // 1 hour
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
- if (!email) return res.status(400).json({ error: 'email is required' });
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
- if (!token || !password) return res.status(400).json({ error: 'token and password are required' });
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
- if (!token || !password) return res.status(400).json({ error: 'token and password are required' });
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<void>")} {
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
- await issueToken(res, { id: user.id, role: user.role, email: user.email });
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
- /** Where the browser goes once signed in. */
3785
- export function successRedirect()${T(": string")} {
3786
- return process.env.OAUTH_SUCCESS_REDIRECT || '/';
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
- export const GET = async ${reqRes("_req")} => {
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
- export const GET = async ${reqRes("_req")} => {
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 'localStorage' (Bearer). Verification emails go through the SendEmail task; `efc add email-client` makes it send real mail.",
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
- ...v.database === "postgresql" && { modelApi: v.modelApi },
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
- let modelApi2;
4516
- if (database === "postgresql") {
4517
- const api = await p.select({
4518
- message: "Models:",
4519
- options: [
4520
- {
4521
- value: "defineTable",
4522
- label: "defineTable",
4523
- hint: "PostgreSQL-native \u2014 foreign keys, relations, include, Prisma-style queries"
4524
- },
4525
- { value: "defineModel", label: "defineModel", hint: "portable \u2014 the same API on MongoDB" }
4526
- ]
4527
- });
4528
- if (p.isCancel(api)) cancel2();
4529
- modelApi2 = api;
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
- 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 = {};
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!