@idevconn/create-icore 0.5.0 → 0.5.1

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.
Files changed (39) hide show
  1. package/dist/cli.js +84 -25
  2. package/dist/index.cjs +81 -24
  3. package/dist/index.d.cts +7 -1
  4. package/dist/index.d.ts +7 -1
  5. package/dist/index.js +80 -24
  6. package/package.json +1 -1
  7. package/templates/apps/api/.env.example +14 -0
  8. package/templates/apps/microservices/auth/package.json +1 -1
  9. package/templates/apps/microservices/auth/src/app/app.module.ts +17 -30
  10. package/templates/apps/microservices/auth/src/main.ts +6 -23
  11. package/templates/apps/microservices/jobs/src/app/redis-connection.ts +35 -0
  12. package/templates/apps/microservices/jobs/src/app/workers/cleanup.worker.ts +2 -1
  13. package/templates/apps/microservices/jobs/src/app/workers/email.worker.ts +2 -1
  14. package/templates/apps/microservices/jobs/src/app/workers/image-process.worker.ts +2 -1
  15. package/templates/apps/microservices/notes/src/app/app.module.ts +22 -27
  16. package/templates/apps/microservices/notes/src/main.ts +6 -23
  17. package/templates/apps/microservices/payment/src/app/app.module.ts +6 -4
  18. package/templates/apps/microservices/payment/src/main.ts +6 -23
  19. package/templates/apps/microservices/upload/package.json +1 -1
  20. package/templates/apps/microservices/upload/src/app/app.module.ts +18 -30
  21. package/templates/apps/microservices/upload/src/main.ts +6 -23
  22. package/templates/libs/firebase-admin/README.md +11 -0
  23. package/templates/libs/firebase-admin/eslint.config.mjs +24 -0
  24. package/templates/libs/firebase-admin/package.json +12 -0
  25. package/templates/libs/firebase-admin/project.json +19 -0
  26. package/templates/libs/firebase-admin/src/index.ts +1 -0
  27. package/templates/libs/firebase-admin/src/lib/__tests__/firebase-admin.unit.test.ts +105 -0
  28. package/templates/libs/firebase-admin/src/lib/firebase-admin.ts +70 -0
  29. package/templates/libs/firebase-admin/tsconfig.json +23 -0
  30. package/templates/libs/firebase-admin/tsconfig.lib.json +23 -0
  31. package/templates/libs/firebase-admin/tsconfig.spec.json +22 -0
  32. package/templates/libs/firebase-admin/vitest.config.mts +21 -0
  33. package/templates/libs/jobs-client/src/lib/jobs-client.service.ts +14 -2
  34. package/templates/libs/shared/src/__tests__/bootstrap.unit.test.ts +92 -0
  35. package/templates/libs/shared/src/__tests__/transport.unit.test.ts +14 -2
  36. package/templates/libs/shared/src/bootstrap.ts +79 -0
  37. package/templates/libs/shared/src/index.ts +1 -0
  38. package/templates/libs/shared/src/transport.ts +25 -3
  39. package/templates/tsconfig.base.json +2 -1
package/dist/index.js CHANGED
@@ -1,3 +1,8 @@
1
+ // src/lib/options.ts
2
+ function pmRun(pm, script) {
3
+ return pm === "npm" ? `npm run ${script}` : `${pm} ${script}`;
4
+ }
5
+
1
6
  // src/lib/scaffold.ts
2
7
  import { copyFile, mkdir, readdir, readFile, stat, writeFile, rm } from "fs/promises";
3
8
  import { readFileSync } from "fs";
@@ -35,6 +40,10 @@ async function rewriteRootPackageJson(targetDir, opts) {
35
40
  pkg["version"] = "0.0.1";
36
41
  pkg["private"] = true;
37
42
  delete pkg.description;
43
+ if (opts.transport === "nats") {
44
+ const deps = pkg["dependencies"] ??= {};
45
+ deps["nats"] = "^2.29.3";
46
+ }
38
47
  if (opts.packageManager !== "yarn") {
39
48
  delete pkg.packageManager;
40
49
  }
@@ -91,9 +100,9 @@ async function writeNotesEnv(targetDir, opts) {
91
100
  async function writeGatewayEnv(targetDir, opts) {
92
101
  const envExample = join(targetDir, "apps/api/.env.example");
93
102
  const env = await readFile(envExample, "utf8");
94
- let next = env.replace(/^AUTH_TRANSPORT=.*$/m, `AUTH_TRANSPORT=${opts.transport}`).replace(/^UPLOAD_TRANSPORT=.*$/m, `UPLOAD_TRANSPORT=${opts.transport}`);
103
+ let next = env.replace(/^AUTH_TRANSPORT=.*$/m, `AUTH_TRANSPORT=${opts.transport}`).replace(/^UPLOAD_TRANSPORT=.*$/m, `UPLOAD_TRANSPORT=${opts.transport}`).replace(/^NOTES_TRANSPORT=.*$/m, `NOTES_TRANSPORT=${opts.transport}`).replace(/^PAYMENT_TRANSPORT=.*$/m, `PAYMENT_TRANSPORT=${opts.transport}`);
95
104
  if (opts.transport !== "tcp") {
96
- next = next.replace(/^# (AUTH_(?:REDIS|NATS)_URL=)/m, "$1").replace(/^# (UPLOAD_(?:REDIS|NATS)_URL=)/m, "$1");
105
+ next = next.replace(/^# (AUTH_(?:REDIS|NATS)_URL=)/m, "$1").replace(/^# (UPLOAD_(?:REDIS|NATS)_URL=)/m, "$1").replace(/^# (NOTES_(?:REDIS|NATS)_URL=)/m, "$1").replace(/^# (PAYMENT_(?:REDIS|NATS)_URL=)/m, "$1");
97
106
  }
98
107
  await writeFile(join(targetDir, "apps/api/.env"), next);
99
108
  }
@@ -106,6 +115,17 @@ async function writeRootEnv(targetDir, opts) {
106
115
  ];
107
116
  await writeFile(join(targetDir, ".env"), lines.join("\n"));
108
117
  }
118
+ async function stripGatewayTransport(targetDir, prefix) {
119
+ const gatewayEnv = join(targetDir, "apps/api/.env");
120
+ try {
121
+ const env = await readFile(gatewayEnv, "utf8");
122
+ const next = env.split("\n").filter(
123
+ (line) => !line.startsWith(`${prefix}_`) && !line.startsWith(`# ${prefix}_`) && !line.includes(`${prefix} MS transport`)
124
+ ).join("\n");
125
+ await writeFile(gatewayEnv, next);
126
+ } catch {
127
+ }
128
+ }
109
129
  async function writeClientEnv(targetDir) {
110
130
  const envExample = join(targetDir, "apps/client/.env.example");
111
131
  try {
@@ -190,6 +210,7 @@ async function removePaymentStack(targetDir) {
190
210
  "@icore/payment-client",
191
211
  "@idevconn/payment"
192
212
  ]);
213
+ await stripGatewayTransport(targetDir, "PAYMENT");
193
214
  }
194
215
  async function removeNotesStack(targetDir) {
195
216
  for (const p2 of [
@@ -211,6 +232,7 @@ async function removeNotesStack(targetDir) {
211
232
  } catch {
212
233
  }
213
234
  await stripDeps(join(targetDir, "apps/api/package.json"), ["@icore/notes-client"]);
235
+ await stripGatewayTransport(targetDir, "NOTES");
214
236
  const tsconfigPath = join(targetDir, "tsconfig.base.json");
215
237
  try {
216
238
  const src = await readFile(tsconfigPath, "utf8");
@@ -267,15 +289,17 @@ async function stripTsconfigPath(targetDir, alias) {
267
289
  }
268
290
  async function removeUnusedAuthStrategies(targetDir, authProvider) {
269
291
  const modulePath = join(targetDir, "apps/microservices/auth/src/app/app.module.ts");
292
+ const AUTH_BRANCH = /if \(provider === 'supabase'\) return makeSupabaseAuth\(cfg\);\n\s*return makeFirebaseAuth\(cfg\);/m;
270
293
  if (authProvider === "supabase") {
271
294
  await rm(join(targetDir, "libs/auth-strategies/firebase"), { recursive: true, force: true });
272
295
  await stripDeps(join(targetDir, "apps/microservices/auth/package.json"), [
273
- "@icore/auth-firebase"
296
+ "@icore/auth-firebase",
297
+ "@icore/firebase-admin"
274
298
  ]);
275
299
  await stripTsconfigPath(targetDir, "@icore/auth-firebase");
276
300
  try {
277
301
  const src = await readFile(modulePath, "utf8");
278
- const next = src.replace(/^import \* as admin from 'firebase-admin';\n/m, "").replace(/^import \{[^}]*FirebaseAuthStrategy[^}]*\} from '@icore\/auth-firebase';\n/m, "").replace(/^function makeFirebaseStrategy\b[\s\S]*?\n^}\n/m, "").replace(/(?<=\n) *case 'firebase':\n *return makeFirebaseStrategy\(cfg\);\n/, "");
302
+ const next = src.replace(/^import \{[^}]*\} from '@icore\/firebase-admin';\n/m, "").replace(/^import \{[^}]*FirebaseAuthStrategy[^}]*\} from '@icore\/auth-firebase';\n/m, "").replace(/^ {2}firebase: \[[^\]]*\],\n/m, "").replace(/\nfunction makeFirebaseAuth[\s\S]*?\n}\n/m, "").replace(AUTH_BRANCH, "return makeSupabaseAuth(cfg);");
279
303
  await writeFile(modulePath, next);
280
304
  } catch {
281
305
  }
@@ -288,10 +312,7 @@ async function removeUnusedAuthStrategies(targetDir, authProvider) {
288
312
  await stripTsconfigPath(targetDir, "@icore/auth-supabase");
289
313
  try {
290
314
  const src = await readFile(modulePath, "utf8");
291
- const next = src.replace(/^import \{ createClient \} from '@supabase\/supabase-js';\n/m, "").replace(/^import \{[^}]*SupabaseAuthStrategy[^}]*\} from '@icore\/auth-supabase';\n/m, "").replace(
292
- /\n {10}case 'supabase': \{[\s\S]*?return new SupabaseAuthStrategy\(\{ client \}\);\n {10}\}\n/m,
293
- ""
294
- );
315
+ const next = src.replace(/^import \{ createClient \} from '@supabase\/supabase-js';\n/m, "").replace(/^import \{[^}]*SupabaseAuthStrategy[^}]*\} from '@icore\/auth-supabase';\n/m, "").replace(/\nfunction makeSupabaseAuth[\s\S]*?\n}\n/m, "").replace(AUTH_BRANCH, "return makeFirebaseAuth(cfg);");
295
316
  await writeFile(modulePath, next);
296
317
  } catch {
297
318
  }
@@ -303,7 +324,8 @@ async function removeUnusedStorageStrategies(targetDir, uploadProvider) {
303
324
  if (uploadProvider !== "firebase") {
304
325
  await rm(join(targetDir, "libs/storage-strategies/firebase"), { recursive: true, force: true });
305
326
  await stripDeps(join(targetDir, "apps/microservices/upload/package.json"), [
306
- "@icore/storage-firebase"
327
+ "@icore/storage-firebase",
328
+ "@icore/firebase-admin"
307
329
  ]);
308
330
  await stripTsconfigPath(targetDir, "@icore/storage-firebase");
309
331
  }
@@ -327,26 +349,26 @@ async function removeUnusedStorageStrategies(targetDir, uploadProvider) {
327
349
  try {
328
350
  let src = await readFile(modulePath, "utf8");
329
351
  if (uploadProvider !== "firebase") {
330
- src = src.replace(/^import \* as admin from 'firebase-admin';\n/m, "").replace(
352
+ src = src.replace(/^import \{[^}]*\} from '@icore\/firebase-admin';\n/m, "").replace(
331
353
  /^import \{[^}]*FirebaseStorageStrategy[^}]*\} from '@icore\/storage-firebase';\n/m,
332
354
  ""
333
- ).replace(/^function makeFirebaseStorage\b[\s\S]*?\n^}\n/m, "").replace(/(?<=\n) *case 'firebase':\n *return makeFirebaseStorage\(cfg\);\n/, "");
355
+ ).replace(/^ {2}firebase: \[[^\]]*\],\n/m, "").replace(/\nfunction makeFirebaseStorage[\s\S]*?\n}\n/m, "");
334
356
  }
335
357
  if (uploadProvider !== "cloudinary") {
336
358
  src = src.replace(/^import \{ v2 as cloudinary \} from 'cloudinary';\n/m, "").replace(
337
359
  /^import \{[^}]*CloudinaryStorageStrategy[^}]*\} from '@icore\/storage-cloudinary';\n/m,
338
360
  ""
339
- ).replace(/^function makeCloudinaryStorage\b[\s\S]*?\n^}\n/m, "").replace(/(?<=\n) *case 'cloudinary':\n *return makeCloudinaryStorage\(cfg\);\n/, "");
361
+ ).replace(/\nfunction makeCloudinaryStorage[\s\S]*?\n}\n/m, "");
340
362
  }
341
363
  if (uploadProvider !== "supabase") {
342
364
  src = src.replace(/^import \{ createClient \} from '@supabase\/supabase-js';\n/m, "").replace(
343
365
  /^import \{[^}]*SupabaseStorageStrategy[^}]*\} from '@icore\/storage-supabase';\n/m,
344
366
  ""
345
- ).replace(
346
- /\n {10}case 'supabase': \{[\s\S]*?bucket: requireEnv\(cfg, 'SUPABASE_STORAGE_BUCKET'\),\n {12}\}\);\n {10}\}\n/m,
347
- ""
348
- );
367
+ ).replace(/\nfunction makeSupabaseStorage[\s\S]*?\n}\n/m, "");
349
368
  }
369
+ const STORAGE_BRANCH = /if \(provider === 'supabase'\) return makeSupabaseStorage\(cfg\);\n\s*if \(provider === 'firebase'\) return makeFirebaseStorage\(cfg\);\n\s*return makeCloudinaryStorage\(cfg\);/m;
370
+ const chosenReturn = `return make${uploadProvider.charAt(0).toUpperCase() + uploadProvider.slice(1)}Storage(cfg);`;
371
+ src = src.replace(STORAGE_BRANCH, chosenReturn);
350
372
  await writeFile(modulePath, src);
351
373
  } catch {
352
374
  }
@@ -356,14 +378,15 @@ async function removeUnusedDbStrategies(targetDir, dbProvider) {
356
378
  if (dbProvider === "supabase") {
357
379
  await rm(join(targetDir, "libs/db-strategies/firestore"), { recursive: true, force: true });
358
380
  await stripDeps(join(targetDir, "apps/microservices/notes/package.json"), [
359
- "@icore/db-firestore"
381
+ "@icore/db-firestore",
382
+ "@icore/firebase-admin"
360
383
  ]);
361
384
  await stripTsconfigPath(targetDir, "@icore/db-firestore");
362
385
  try {
363
386
  const src = await readFile(modulePath, "utf8");
364
- const next = src.replace(/^import \* as admin from 'firebase-admin';\n/m, "").replace(/^import \{[^}]*FirestoreDBStrategy[^}]*\} from '@icore\/db-firestore';\n/m, "").replace(
365
- /\n {8}if \(provider === 'firestore'[\s\S]*?return new FirestoreDBStrategy\(\{[\s\S]*?\}\);\n {8}\}\n/m,
366
- ""
387
+ const next = src.replace(/^import \{[^}]*\} from '@icore\/firebase-admin';\n/m, "").replace(/^import \{[^}]*FirestoreDBStrategy[^}]*\} from '@icore\/db-firestore';\n/m, "").replace(/^ {2}firestore: \[[^\]]*\],\n/m, "").replace(/^ {2}firebase: \[[^\]]*\],\n/m, "").replace(/\nfunction makeFirestoreDB[\s\S]*?\n}\n/m, "").replace(
388
+ /if \(provider === 'supabase'\) return makeSupabaseDB\(cfg\);\n\s*return makeFirestoreDB\(cfg\);/m,
389
+ "return makeSupabaseDB(cfg);"
367
390
  );
368
391
  await writeFile(modulePath, next);
369
392
  } catch {
@@ -377,15 +400,19 @@ async function removeUnusedDbStrategies(targetDir, dbProvider) {
377
400
  await stripTsconfigPath(targetDir, "@icore/db-supabase");
378
401
  try {
379
402
  const src = await readFile(modulePath, "utf8");
380
- const next = src.replace(/^import \{ createClient \} from '@supabase\/supabase-js';\n/m, "").replace(/^import \{[^}]*SupabaseDBStrategy[^}]*\} from '@icore\/db-supabase';\n/m, "").replace(
381
- /\n {8}if \(provider === 'supabase'\) \{[\s\S]*?return new SupabaseDBStrategy\(\{ client \}\);\n {8}\}\n/m,
382
- ""
403
+ const next = src.replace(/^import \{ createClient \} from '@supabase\/supabase-js';\n/m, "").replace(/^import \{[^}]*SupabaseDBStrategy[^}]*\} from '@icore\/db-supabase';\n/m, "").replace(/\nfunction makeSupabaseDB[\s\S]*?\n}\n/m, "").replace(
404
+ /if \(provider === 'supabase'\) return makeSupabaseDB\(cfg\);\n\s*return makeFirestoreDB\(cfg\);/m,
405
+ "return makeFirestoreDB(cfg);"
383
406
  );
384
407
  await writeFile(modulePath, next);
385
408
  } catch {
386
409
  }
387
410
  }
388
411
  }
412
+ async function removeFirebaseAdminLib(targetDir) {
413
+ await rm(join(targetDir, "libs/firebase-admin"), { recursive: true, force: true });
414
+ await stripTsconfigPath(targetDir, "@icore/firebase-admin");
415
+ }
389
416
  async function removeUploadStack(targetDir) {
390
417
  const paths = [
391
418
  "apps/microservices/upload",
@@ -498,17 +525,45 @@ async function scaffold(opts, templatesDir) {
498
525
  await removeUnusedAuthStrategies(opts.targetDir, opts.authProvider);
499
526
  await removeUnusedStorageStrategies(opts.targetDir, opts.upload);
500
527
  await removeUnusedDbStrategies(opts.targetDir, opts.dbProvider);
528
+ const firebaseUsed = opts.authProvider === "firebase" || opts.dbProvider === "firebase" || opts.upload === "firebase";
529
+ if (!firebaseUsed) await removeFirebaseAdminLib(opts.targetDir);
501
530
  if (opts.packageManager === "yarn") {
502
531
  await writeFile(join(opts.targetDir, "yarn.lock"), "");
532
+ } else {
533
+ await rm(join(opts.targetDir, ".yarn"), { recursive: true, force: true });
534
+ await rm(join(opts.targetDir, ".yarnrc.yml"), { force: true });
503
535
  }
536
+ await patchGitignoreForPm(opts.targetDir, opts.packageManager);
504
537
  await writeAiFiles(opts.targetDir, opts);
505
538
  if (opts.install) runInstall(opts.targetDir, opts.packageManager);
506
539
  if (opts.initGit) gitInit(opts.targetDir, opts.projectName);
507
540
  }
541
+ async function patchGitignoreForPm(targetDir, pm) {
542
+ const giPath = join(targetDir, ".gitignore");
543
+ try {
544
+ let src = await readFile(giPath, "utf8");
545
+ src = src.replace(/^# Build artifacts.*\ntools\/create-icore\/templates\/\s*\n/m, "");
546
+ if (pm !== "yarn") {
547
+ src = src.replace(/^\.yarn\/\*\s*\n/m, "").replace(/^!\.yarn\/patches\s*\n/m, "").replace(/^!\.yarn\/plugins\s*\n/m, "").replace(/^!\.yarn\/releases\s*\n/m, "").replace(/^!\.yarn\/sdks\s*\n/m, "").replace(/^!\.yarn\/versions\s*\n/m, "").replace(/^\.pnp\.\*\s*\n/m, "");
548
+ }
549
+ if (pm === "pnpm") {
550
+ if (!src.includes(".pnpm-debug.log")) {
551
+ src += "\n# pnpm\n.pnpm-debug.log*\n";
552
+ }
553
+ }
554
+ if (pm === "npm") {
555
+ if (!src.includes("npm-debug.log")) {
556
+ src += "\n# npm\nnpm-debug.log*\n";
557
+ }
558
+ }
559
+ await writeFile(giPath, src);
560
+ } catch {
561
+ }
562
+ }
508
563
  async function writeAiFiles(targetDir, opts) {
509
564
  const pm = opts.packageManager;
510
565
  const nx = pm === "npm" ? "npx nx" : `${pm} nx`;
511
- const devCmd = `${pm} dev`;
566
+ const devCmd = pmRun(pm, "dev");
512
567
  const activeMSes = ["auth (port 4001)"];
513
568
  if (opts.upload !== "none") activeMSes.push(`upload (port 4002)`);
514
569
  if (opts.payment !== "none") activeMSes.push(`payment (port 4003)`);
@@ -918,5 +973,6 @@ Re-run with @latest to refresh:
918
973
  }
919
974
  export {
920
975
  collectOptions,
976
+ pmRun,
921
977
  scaffold
922
978
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idevconn/create-icore",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Bootstrap a new project from the iCore scaffold (Nx + NestJS + React + Vite + shadcn/Tailwind, swappable auth + storage providers).",
5
5
  "license": "Apache-2.0",
6
6
  "author": "iDEVconn",
@@ -15,5 +15,19 @@ UPLOAD_PORT=4002
15
15
  # UPLOAD_REDIS_URL=redis://localhost:6379
16
16
  # UPLOAD_NATS_URL=nats://localhost:4222
17
17
 
18
+ # Notes MS transport — must match apps/microservices/notes/.env
19
+ NOTES_TRANSPORT=tcp
20
+ NOTES_HOST=127.0.0.1
21
+ NOTES_PORT=4004
22
+ # NOTES_REDIS_URL=redis://localhost:6379
23
+ # NOTES_NATS_URL=nats://localhost:4222
24
+
25
+ # Payment MS transport — must match apps/microservices/payment/.env
26
+ PAYMENT_TRANSPORT=tcp
27
+ PAYMENT_HOST=127.0.0.1
28
+ PAYMENT_PORT=4003
29
+ # PAYMENT_REDIS_URL=redis://localhost:6379
30
+ # PAYMENT_NATS_URL=nats://localhost:4222
31
+
18
32
  # Per-request multipart file size cap (KB). Default 5120 (5 MB) when unset.
19
33
  MAX_FILE_SIZE_KB=5120
@@ -5,8 +5,8 @@
5
5
  "dependencies": {
6
6
  "@icore/auth-firebase": "*",
7
7
  "@icore/auth-supabase": "*",
8
+ "@icore/firebase-admin": "*",
8
9
  "@icore/shared": "*",
9
- "firebase-admin": "^13.0.0",
10
10
  "@nestjs/common": "^11.1.24",
11
11
  "@nestjs/config": "^4.0.4",
12
12
  "@nestjs/core": "^11.1.24",
@@ -1,13 +1,12 @@
1
1
  import { join } from 'node:path';
2
- import { Module } from '@nestjs/common';
2
+ import { Module, Logger } from '@nestjs/common';
3
3
  import { ConfigModule, ConfigService } from '@nestjs/config';
4
4
  import { createClient } from '@supabase/supabase-js';
5
- import * as admin from 'firebase-admin';
6
5
  import { SupabaseAuthStrategy } from '@icore/auth-supabase';
7
6
  import { FirebaseAuthStrategy, HttpIdentityToolkitClient } from '@icore/auth-firebase';
7
+ import { getFirebaseAdmin, FIREBASE_ADMIN_REQUIRED_ENV } from '@icore/firebase-admin';
8
8
  import { FakeAuthStrategy, missingEnv, formatEnvBanner } from '@icore/shared';
9
9
  import type { AuthStrategy } from '@icore/shared';
10
- import { Logger } from '@nestjs/common';
11
10
  import { AuthController } from './auth.controller';
12
11
 
13
12
  const ENV_PATH = 'apps/microservices/auth/.env';
@@ -15,33 +14,28 @@ const ENV_PATH = 'apps/microservices/auth/.env';
15
14
  // Env vars each provider needs (besides AUTH_PROVIDER itself).
16
15
  const REQUIRED_ENV: Record<string, string[]> = {
17
16
  supabase: ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY'],
18
- firebase: [
19
- 'FB_ADMIN_PROJECT_ID',
20
- 'FB_ADMIN_CLIENT_EMAIL',
21
- 'FB_ADMIN_PRIVATE_KEY',
22
- 'FIREBASE_WEB_API_KEY',
23
- ],
17
+ firebase: [...FIREBASE_ADMIN_REQUIRED_ENV, 'FIREBASE_WEB_API_KEY'],
24
18
  };
25
19
 
26
20
  function requireEnv(cfg: ConfigService, key: string): string {
27
21
  return cfg.getOrThrow<string>(key);
28
22
  }
29
23
 
30
- function makeFirebaseStrategy(cfg: ConfigService): AuthStrategy {
31
- const projectId = requireEnv(cfg, 'FB_ADMIN_PROJECT_ID');
32
- if (admin.apps.length === 0) {
33
- admin.initializeApp({
34
- credential: admin.credential.cert({
35
- projectId,
36
- clientEmail: requireEnv(cfg, 'FB_ADMIN_CLIENT_EMAIL'),
37
- privateKey: requireEnv(cfg, 'FB_ADMIN_PRIVATE_KEY').replace(/\\n/g, '\n'),
38
- }),
39
- });
40
- }
24
+ function makeSupabaseAuth(cfg: ConfigService): AuthStrategy {
25
+ const client = createClient(
26
+ requireEnv(cfg, 'SUPABASE_URL'),
27
+ requireEnv(cfg, 'SUPABASE_SERVICE_ROLE_KEY'),
28
+ { auth: { autoRefreshToken: false, persistSession: false } },
29
+ );
30
+ return new SupabaseAuthStrategy({ client });
31
+ }
32
+
33
+ function makeFirebaseAuth(cfg: ConfigService): AuthStrategy {
34
+ const app = getFirebaseAdmin(cfg);
41
35
  const identityToolkit = new HttpIdentityToolkitClient(requireEnv(cfg, 'FIREBASE_WEB_API_KEY'));
42
36
  return new FirebaseAuthStrategy({
43
37
  identityToolkit,
44
- adminAuth: admin.auth(),
38
+ adminAuth: app.auth(),
45
39
  });
46
40
  }
47
41
 
@@ -83,15 +77,8 @@ function makeFirebaseStrategy(cfg: ConfigService): AuthStrategy {
83
77
  if (!keys || missing.length > 0) return fallback();
84
78
 
85
79
  try {
86
- if (provider === 'supabase') {
87
- const client = createClient(
88
- requireEnv(cfg, 'SUPABASE_URL'),
89
- requireEnv(cfg, 'SUPABASE_SERVICE_ROLE_KEY'),
90
- { auth: { autoRefreshToken: false, persistSession: false } },
91
- );
92
- return new SupabaseAuthStrategy({ client });
93
- }
94
- return makeFirebaseStrategy(cfg);
80
+ if (provider === 'supabase') return makeSupabaseAuth(cfg);
81
+ return makeFirebaseAuth(cfg);
95
82
  } catch (err) {
96
83
  // Vars present but invalid (e.g. placeholder URL the SDK rejects).
97
84
  return fallback(err instanceof Error ? err.message : String(err));
@@ -1,28 +1,11 @@
1
1
  import { Logger } from '@nestjs/common';
2
2
  import { NestFactory } from '@nestjs/core';
3
3
  import { MicroserviceOptions } from '@nestjs/microservices';
4
- import { buildTransportMS } from '@icore/shared';
4
+ import { bootstrapMicroservice, buildTransportMS } from '@icore/shared';
5
5
  import { AppModule } from './app/app.module';
6
6
 
7
- async function bootstrap() {
8
- const app = await NestFactory.createMicroservice<MicroserviceOptions>(
9
- AppModule,
10
- buildTransportMS('AUTH'),
11
- );
12
- await app.listen();
13
- }
14
-
15
- bootstrap()
16
- .then(() => {
17
- const logger = new Logger('Auth-Bootstrap');
18
- logger.log(
19
- `Auth MS Bootstrap completed: transport=${process.env.AUTH_TRANSPORT ?? 'tcp'} host=${process.env.AUTH_HOST ?? '127.0.0.1'} port=${process.env.AUTH_PORT ?? '4001'}`,
20
- );
21
- })
22
- .catch((err) => {
23
- new Logger('Auth-Bootstrap').error(
24
- 'Auth MS bootstrap failed',
25
- err instanceof Error ? err.stack : err,
26
- );
27
- process.exit(1);
28
- });
7
+ void bootstrapMicroservice(
8
+ 'AUTH',
9
+ () => NestFactory.createMicroservice<MicroserviceOptions>(AppModule, buildTransportMS('AUTH')),
10
+ new Logger('Auth-Bootstrap'),
11
+ );
@@ -0,0 +1,35 @@
1
+ import { Logger } from '@nestjs/common';
2
+ import IORedis from 'ioredis';
3
+ import { formatEnvBanner } from '@icore/shared';
4
+
5
+ /**
6
+ * Creates an IORedis connection that NEVER crashes the process when Redis is
7
+ * unreachable. Without an 'error' handler, ioredis emits an unhandled 'error'
8
+ * event → Node exits. Here we log one boxed banner and let ioredis keep
9
+ * retrying in the background, so the jobs MS stays up and connects once Redis
10
+ * is available (honours the "never crash on missing infra" rule).
11
+ */
12
+ export function createJobsRedis(url: string, logger: Logger): IORedis {
13
+ const connection = new IORedis(url, {
14
+ maxRetriesPerRequest: null,
15
+ retryStrategy: (times) => Math.min(times * 200, 5000),
16
+ });
17
+
18
+ let warned = false;
19
+ connection.on('error', (err: Error) => {
20
+ if (warned) return;
21
+ warned = true;
22
+ logger.warn(
23
+ formatEnvBanner({
24
+ service: 'jobs MS',
25
+ provider: 'redis',
26
+ missing: [],
27
+ envPath: 'apps/microservices/jobs/.env (JOBS_REDIS_URL)',
28
+ reason: `${err.message} — retrying in the background until Redis is up at ${url}`,
29
+ headline: `⚠ jobs MS — Redis unreachable (workers idle until it's up)`,
30
+ }),
31
+ );
32
+ });
33
+
34
+ return connection;
35
+ }
@@ -2,6 +2,7 @@ import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@ne
2
2
  import { ConfigService } from '@nestjs/config';
3
3
  import { Worker, type Job } from 'bullmq';
4
4
  import IORedis from 'ioredis';
5
+ import { createJobsRedis } from '../redis-connection';
5
6
  import type { CleanupJob } from '@icore/shared';
6
7
 
7
8
  @Injectable()
@@ -15,7 +16,7 @@ export class CleanupWorker implements OnModuleInit, OnModuleDestroy {
15
16
  onModuleInit(): void {
16
17
  const url = this.cfg.get<string>('JOBS_REDIS_URL') ?? 'redis://localhost:6379';
17
18
  const concurrency = Number(this.cfg.get<string>('JOBS_WORKER_CONCURRENCY') ?? '5');
18
- this.connection = new IORedis(url, { maxRetriesPerRequest: null });
19
+ this.connection = createJobsRedis(url, this.logger);
19
20
  this.worker = new Worker<CleanupJob>(
20
21
  'cleanup',
21
22
  async (job: Job<CleanupJob>) => {
@@ -2,6 +2,7 @@ import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@ne
2
2
  import { ConfigService } from '@nestjs/config';
3
3
  import { Worker, type Job } from 'bullmq';
4
4
  import IORedis from 'ioredis';
5
+ import { createJobsRedis } from '../redis-connection';
5
6
  import type { EmailJob } from '@icore/shared';
6
7
 
7
8
  @Injectable()
@@ -15,7 +16,7 @@ export class EmailWorker implements OnModuleInit, OnModuleDestroy {
15
16
  onModuleInit(): void {
16
17
  const url = this.cfg.get<string>('JOBS_REDIS_URL') ?? 'redis://localhost:6379';
17
18
  const concurrency = Number(this.cfg.get<string>('JOBS_WORKER_CONCURRENCY') ?? '5');
18
- this.connection = new IORedis(url, { maxRetriesPerRequest: null });
19
+ this.connection = createJobsRedis(url, this.logger);
19
20
  this.worker = new Worker<EmailJob>(
20
21
  'email',
21
22
  async (job: Job<EmailJob>) => {
@@ -2,6 +2,7 @@ import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@ne
2
2
  import { ConfigService } from '@nestjs/config';
3
3
  import { Worker, type Job } from 'bullmq';
4
4
  import IORedis from 'ioredis';
5
+ import { createJobsRedis } from '../redis-connection';
5
6
  import type { ImageProcessJob } from '@icore/shared';
6
7
 
7
8
  @Injectable()
@@ -15,7 +16,7 @@ export class ImageProcessWorker implements OnModuleInit, OnModuleDestroy {
15
16
  onModuleInit(): void {
16
17
  const url = this.cfg.get<string>('JOBS_REDIS_URL') ?? 'redis://localhost:6379';
17
18
  const concurrency = Number(this.cfg.get<string>('JOBS_WORKER_CONCURRENCY') ?? '5');
18
- this.connection = new IORedis(url, { maxRetriesPerRequest: null });
19
+ this.connection = createJobsRedis(url, this.logger);
19
20
  this.worker = new Worker<ImageProcessJob>(
20
21
  'image-process',
21
22
  async (job: Job<ImageProcessJob>) => {
@@ -1,13 +1,12 @@
1
1
  import { join } from 'node:path';
2
- import { Module } from '@nestjs/common';
2
+ import { Module, Logger } from '@nestjs/common';
3
3
  import { ConfigModule, ConfigService } from '@nestjs/config';
4
4
  import { createClient } from '@supabase/supabase-js';
5
- import * as admin from 'firebase-admin';
6
5
  import { SupabaseDBStrategy } from '@icore/db-supabase';
7
6
  import { FirestoreDBStrategy } from '@icore/db-firestore';
7
+ import { getFirebaseAdmin, FIREBASE_ADMIN_REQUIRED_ENV } from '@icore/firebase-admin';
8
8
  import { FakeDBStrategy, missingEnv, formatEnvBanner } from '@icore/shared';
9
9
  import type { DBStrategy } from '@icore/shared';
10
- import { Logger } from '@nestjs/common';
11
10
  import { NotesController } from './notes.controller';
12
11
 
13
12
  const ENV_PATH = 'apps/microservices/notes/.env';
@@ -15,14 +14,30 @@ const ENV_PATH = 'apps/microservices/notes/.env';
15
14
  // DB_PROVIDER accepts supabase | firestore | firebase (latter two are Firestore).
16
15
  const REQUIRED_ENV: Record<string, string[]> = {
17
16
  supabase: ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY'],
18
- firestore: ['FB_ADMIN_PROJECT_ID', 'FB_ADMIN_CLIENT_EMAIL', 'FB_ADMIN_PRIVATE_KEY'],
19
- firebase: ['FB_ADMIN_PROJECT_ID', 'FB_ADMIN_CLIENT_EMAIL', 'FB_ADMIN_PRIVATE_KEY'],
17
+ firestore: [...FIREBASE_ADMIN_REQUIRED_ENV],
18
+ firebase: [...FIREBASE_ADMIN_REQUIRED_ENV],
20
19
  };
21
20
 
22
21
  function requireEnv(cfg: ConfigService, key: string): string {
23
22
  return cfg.getOrThrow<string>(key);
24
23
  }
25
24
 
25
+ function makeSupabaseDB(cfg: ConfigService): DBStrategy {
26
+ const client = createClient(
27
+ requireEnv(cfg, 'SUPABASE_URL'),
28
+ requireEnv(cfg, 'SUPABASE_SERVICE_ROLE_KEY'),
29
+ { auth: { autoRefreshToken: false, persistSession: false } },
30
+ );
31
+ return new SupabaseDBStrategy({ client });
32
+ }
33
+
34
+ function makeFirestoreDB(cfg: ConfigService): DBStrategy {
35
+ const app = getFirebaseAdmin(cfg);
36
+ return new FirestoreDBStrategy({
37
+ db: app.firestore() as unknown as ConstructorParameters<typeof FirestoreDBStrategy>[0]['db'],
38
+ });
39
+ }
40
+
26
41
  @Module({
27
42
  imports: [
28
43
  ConfigModule.forRoot({
@@ -59,28 +74,8 @@ function requireEnv(cfg: ConfigService, key: string): string {
59
74
  if (!keys || missing.length > 0) return fallback();
60
75
 
61
76
  try {
62
- if (provider === 'supabase') {
63
- const client = createClient(
64
- requireEnv(cfg, 'SUPABASE_URL'),
65
- requireEnv(cfg, 'SUPABASE_SERVICE_ROLE_KEY'),
66
- { auth: { autoRefreshToken: false, persistSession: false } },
67
- );
68
- return new SupabaseDBStrategy({ client });
69
- }
70
- if (admin.apps.length === 0) {
71
- admin.initializeApp({
72
- credential: admin.credential.cert({
73
- projectId: requireEnv(cfg, 'FB_ADMIN_PROJECT_ID'),
74
- clientEmail: requireEnv(cfg, 'FB_ADMIN_CLIENT_EMAIL'),
75
- privateKey: requireEnv(cfg, 'FB_ADMIN_PRIVATE_KEY').replace(/\\n/g, '\n'),
76
- }),
77
- });
78
- }
79
- return new FirestoreDBStrategy({
80
- db: admin.firestore() as unknown as ConstructorParameters<
81
- typeof FirestoreDBStrategy
82
- >[0]['db'],
83
- });
77
+ if (provider === 'supabase') return makeSupabaseDB(cfg);
78
+ return makeFirestoreDB(cfg);
84
79
  } catch (err) {
85
80
  return fallback(err instanceof Error ? err.message : String(err));
86
81
  }
@@ -1,28 +1,11 @@
1
1
  import { Logger } from '@nestjs/common';
2
2
  import { NestFactory } from '@nestjs/core';
3
3
  import { MicroserviceOptions } from '@nestjs/microservices';
4
- import { buildTransportMS } from '@icore/shared';
4
+ import { bootstrapMicroservice, buildTransportMS } from '@icore/shared';
5
5
  import { AppModule } from './app/app.module';
6
6
 
7
- async function bootstrap() {
8
- const app = await NestFactory.createMicroservice<MicroserviceOptions>(
9
- AppModule,
10
- buildTransportMS('NOTES'),
11
- );
12
- await app.listen();
13
- }
14
-
15
- bootstrap()
16
- .then(() => {
17
- const logger = new Logger('Notes-Bootstrap');
18
- logger.log(
19
- `Notes MS Bootstrap completed: transport=${process.env.NOTES_TRANSPORT ?? 'tcp'} host=${process.env.NOTES_HOST ?? '127.0.0.1'} port=${process.env.NOTES_PORT ?? '4004'}`,
20
- );
21
- })
22
- .catch((err) => {
23
- new Logger('Notes-Bootstrap').error(
24
- 'Notes MS bootstrap failed',
25
- err instanceof Error ? err.stack : err,
26
- );
27
- process.exit(1);
28
- });
7
+ void bootstrapMicroservice(
8
+ 'NOTES',
9
+ () => NestFactory.createMicroservice<MicroserviceOptions>(AppModule, buildTransportMS('NOTES')),
10
+ new Logger('Notes-Bootstrap'),
11
+ );
@@ -40,17 +40,19 @@ const REQUIRED_ENV: Record<string, string[]> = {
40
40
  envPath: ENV_PATH,
41
41
  headline: `⚠ payment MS — ${provider} credentials missing (payments will fail)`,
42
42
  });
43
- // Prod: fail fast. Dev: warn PayPal is lazy, so the MS still boots
44
- // and only payment calls fail until creds are set.
43
+ // Prod: fail fast. Dev: warn + register an EMPTY strategy map so the
44
+ // MS boots (PaypalStrategy's constructor throws on blank creds, so we
45
+ // must not instantiate it) — payment endpoints fail until creds are set.
45
46
  if (process.env.NODE_ENV === 'production') throw new Error(banner);
46
47
  logger.warn(banner);
48
+ return createPayment({ strategies: {} });
47
49
  }
48
50
 
49
51
  return createPayment({
50
52
  strategies: {
51
53
  paypal: new PaypalStrategy({
52
- clientId: cfg.get<string>('PAYPAL_CLIENT_ID') ?? '',
53
- secret: cfg.get<string>('PAYPAL_CLIENT_SECRET') ?? '',
54
+ clientId: cfg.getOrThrow<string>('PAYPAL_CLIENT_ID'),
55
+ secret: cfg.getOrThrow<string>('PAYPAL_CLIENT_SECRET'),
54
56
  environment: cfg.get<'sandbox' | 'live'>('PAYPAL_ENVIRONMENT') ?? 'sandbox',
55
57
  }),
56
58
  },