@bloomneo/appkit 1.2.9 → 1.5.2

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 (118) hide show
  1. package/AGENTS.md +195 -0
  2. package/CHANGELOG.md +253 -0
  3. package/README.md +147 -799
  4. package/bin/commands/generate.js +7 -7
  5. package/cookbook/README.md +26 -0
  6. package/cookbook/api-key-service.ts +106 -0
  7. package/cookbook/auth-protected-crud.ts +112 -0
  8. package/cookbook/file-upload-pipeline.ts +113 -0
  9. package/cookbook/multi-tenant-saas.ts +87 -0
  10. package/cookbook/real-time-chat.ts +121 -0
  11. package/dist/auth/auth.d.ts +21 -4
  12. package/dist/auth/auth.d.ts.map +1 -1
  13. package/dist/auth/auth.js +56 -44
  14. package/dist/auth/auth.js.map +1 -1
  15. package/dist/auth/defaults.d.ts +1 -1
  16. package/dist/auth/defaults.js +35 -35
  17. package/dist/cache/cache.d.ts +29 -6
  18. package/dist/cache/cache.d.ts.map +1 -1
  19. package/dist/cache/cache.js +72 -44
  20. package/dist/cache/cache.js.map +1 -1
  21. package/dist/cache/defaults.js +29 -29
  22. package/dist/cache/index.d.ts +19 -10
  23. package/dist/cache/index.d.ts.map +1 -1
  24. package/dist/cache/index.js +21 -18
  25. package/dist/cache/index.js.map +1 -1
  26. package/dist/config/defaults.d.ts +1 -1
  27. package/dist/config/defaults.js +11 -11
  28. package/dist/config/index.d.ts +3 -3
  29. package/dist/config/index.js +4 -4
  30. package/dist/database/adapters/mongoose.d.ts +4 -4
  31. package/dist/database/adapters/mongoose.js +7 -7
  32. package/dist/database/adapters/prisma.d.ts +4 -4
  33. package/dist/database/adapters/prisma.js +7 -7
  34. package/dist/database/defaults.d.ts +1 -1
  35. package/dist/database/defaults.js +4 -4
  36. package/dist/database/index.js +2 -2
  37. package/dist/database/index.js.map +1 -1
  38. package/dist/email/defaults.js +26 -26
  39. package/dist/email/index.js +7 -7
  40. package/dist/email/strategies/resend.js +1 -1
  41. package/dist/error/defaults.d.ts +1 -1
  42. package/dist/error/defaults.js +13 -13
  43. package/dist/error/error.d.ts +12 -0
  44. package/dist/error/error.d.ts.map +1 -1
  45. package/dist/error/error.js +19 -0
  46. package/dist/error/error.js.map +1 -1
  47. package/dist/error/index.d.ts +14 -3
  48. package/dist/error/index.d.ts.map +1 -1
  49. package/dist/error/index.js +14 -3
  50. package/dist/error/index.js.map +1 -1
  51. package/dist/event/defaults.js +35 -35
  52. package/dist/event/index.js +7 -7
  53. package/dist/logger/defaults.d.ts +1 -1
  54. package/dist/logger/defaults.js +40 -40
  55. package/dist/logger/index.d.ts +1 -0
  56. package/dist/logger/index.d.ts.map +1 -1
  57. package/dist/logger/index.js.map +1 -1
  58. package/dist/logger/logger.d.ts +8 -0
  59. package/dist/logger/logger.d.ts.map +1 -1
  60. package/dist/logger/logger.js +13 -3
  61. package/dist/logger/logger.js.map +1 -1
  62. package/dist/logger/transports/console.js +2 -2
  63. package/dist/logger/transports/http.d.ts +1 -1
  64. package/dist/logger/transports/http.js +2 -2
  65. package/dist/logger/transports/webhook.d.ts +1 -1
  66. package/dist/logger/transports/webhook.js +3 -3
  67. package/dist/queue/defaults.d.ts +2 -2
  68. package/dist/queue/defaults.js +38 -38
  69. package/dist/security/defaults.d.ts +1 -1
  70. package/dist/security/defaults.js +30 -30
  71. package/dist/security/index.d.ts +1 -1
  72. package/dist/security/index.js +3 -3
  73. package/dist/security/security.d.ts +1 -1
  74. package/dist/security/security.js +4 -4
  75. package/dist/storage/defaults.js +26 -26
  76. package/dist/storage/index.js +3 -3
  77. package/dist/util/defaults.d.ts +1 -1
  78. package/dist/util/defaults.js +41 -41
  79. package/dist/util/env.d.ts +35 -0
  80. package/dist/util/env.d.ts.map +1 -0
  81. package/dist/util/env.js +50 -0
  82. package/dist/util/env.js.map +1 -0
  83. package/dist/util/errors.d.ts +52 -0
  84. package/dist/util/errors.d.ts.map +1 -0
  85. package/dist/util/errors.js +82 -0
  86. package/dist/util/errors.js.map +1 -0
  87. package/dist/util/util.js +1 -1
  88. package/examples/.env.example +80 -0
  89. package/examples/README.md +16 -0
  90. package/examples/auth.ts +228 -0
  91. package/examples/cache.ts +36 -0
  92. package/examples/config.ts +45 -0
  93. package/examples/database.ts +69 -0
  94. package/examples/email.ts +53 -0
  95. package/examples/error.ts +50 -0
  96. package/examples/event.ts +42 -0
  97. package/examples/logger.ts +41 -0
  98. package/examples/queue.ts +58 -0
  99. package/examples/security.ts +46 -0
  100. package/examples/storage.ts +44 -0
  101. package/examples/util.ts +47 -0
  102. package/llms.txt +591 -0
  103. package/package.json +19 -10
  104. package/src/auth/README.md +850 -0
  105. package/src/cache/README.md +756 -0
  106. package/src/config/README.md +604 -0
  107. package/src/database/README.md +818 -0
  108. package/src/email/README.md +759 -0
  109. package/src/error/README.md +660 -0
  110. package/src/event/README.md +729 -0
  111. package/src/logger/README.md +435 -0
  112. package/src/queue/README.md +851 -0
  113. package/src/security/README.md +612 -0
  114. package/src/storage/README.md +1008 -0
  115. package/src/util/README.md +955 -0
  116. package/bin/templates/backend/docs/APPKIT_CLI.md +0 -507
  117. package/bin/templates/backend/docs/APPKIT_COMMENTS_GUIDELINES.md +0 -61
  118. package/bin/templates/backend/docs/APPKIT_LLM_GUIDE.md +0 -2539
@@ -555,13 +555,13 @@ async function generateFromTemplate(
555
555
  const envPath = path.join(currentDir, '.env');
556
556
  const envContent = await fs.readFile(envPath, 'utf8');
557
557
  const keyMatch = envContent.match(
558
- /VOILA_FRONTEND_KEY\s*=\s*["']?([^"'\n\r]+)["']?/
558
+ /BLOOM_FRONTEND_KEY\s*=\s*["']?([^"'\n\r]+)["']?/
559
559
  );
560
560
  if (keyMatch) {
561
561
  frontendKey = keyMatch[1];
562
562
  }
563
563
  const authMatch = envContent.match(
564
- /VOILA_AUTH_SECRET\s*=\s*["']?([^"'\n\r]+)["']?/
564
+ /BLOOM_AUTH_SECRET\s*=\s*["']?([^"'\n\r]+)["']?/
565
565
  );
566
566
  if (authMatch) {
567
567
  authSecret = authMatch[1];
@@ -989,7 +989,7 @@ async function generateUserSeedingFiles(templatesPath, projectDir) {
989
989
  }
990
990
 
991
991
  /**
992
- * Ensure DATABASE_URL, VOILA_AUTH_SECRET, and DEFAULT_USER_PASSWORD exist in .env
992
+ * Ensure DATABASE_URL, BLOOM_AUTH_SECRET, and DEFAULT_USER_PASSWORD exist in .env
993
993
  */
994
994
  async function ensureDatabaseUrl(projectDir) {
995
995
  const envPath = path.join(projectDir, '.env');
@@ -1013,17 +1013,17 @@ async function ensureDatabaseUrl(projectDir) {
1013
1013
  console.log(`✅ Added DATABASE_URL to .env`);
1014
1014
  }
1015
1015
 
1016
- // Check if VOILA_AUTH_SECRET already exists
1017
- if (!envContent.includes('VOILA_AUTH_SECRET=')) {
1016
+ // Check if BLOOM_AUTH_SECRET already exists
1017
+ if (!envContent.includes('BLOOM_AUTH_SECRET=')) {
1018
1018
  const authSecret =
1019
1019
  'auth_' +
1020
1020
  Math.random().toString(36).substring(2, 15) +
1021
1021
  Math.random().toString(36).substring(2, 15) +
1022
1022
  Math.random().toString(36).substring(2, 15);
1023
- const authSecretLine = '\nVOILA_AUTH_SECRET=' + authSecret + '\n';
1023
+ const authSecretLine = '\nBLOOM_AUTH_SECRET=' + authSecret + '\n';
1024
1024
  envContent += authSecretLine;
1025
1025
  updated = true;
1026
- console.log(`✅ Added VOILA_AUTH_SECRET to .env`);
1026
+ console.log(`✅ Added BLOOM_AUTH_SECRET to .env`);
1027
1027
  }
1028
1028
 
1029
1029
  // Check if DEFAULT_USER_PASSWORD already exists
@@ -0,0 +1,26 @@
1
+ # Cookbook
2
+
3
+ Composed multi-module recipes. Whole-pattern files showing how the 12 modules
4
+ work together for common backend scenarios. Copy a recipe, swap the data
5
+ source, ship.
6
+
7
+ For single-module examples (one canonical file per module), see
8
+ [`../examples/`](../examples/).
9
+
10
+ ## Recipes
11
+
12
+ | File | Pattern |
13
+ |---|---|
14
+ | [`auth-protected-crud.ts`](./auth-protected-crud.ts) | Auth + database + error + logger — full CRUD endpoint with role-based access |
15
+ | [`multi-tenant-saas.ts`](./multi-tenant-saas.ts) | Multi-tenant database + cache + per-tenant rate limiting |
16
+ | [`file-upload-pipeline.ts`](./file-upload-pipeline.ts) | Storage + queue + logger — upload → background processing → result |
17
+ | [`real-time-chat.ts`](./real-time-chat.ts) | Event + auth + database — WebSocket-friendly real-time event flow |
18
+ | [`api-key-service.ts`](./api-key-service.ts) | Auth (API tokens) + security (encryption) + rate limiting — third-party API key management |
19
+
20
+ ## Conventions
21
+
22
+ - All recipes use **flat imports** from `@bloomneo/appkit`.
23
+ - All recipes follow the canonical `xxxClass.get()` pattern.
24
+ - All recipes wrap async route handlers in `error.asyncRoute(...)`.
25
+ - All recipes mount `error.handleErrors()` last in the Express middleware stack.
26
+ - All recipes use `BLOOM_*` env vars (the `VOILA_*` prefix was removed in 1.5.2).
@@ -0,0 +1,106 @@
1
+ /**
2
+ * COOKBOOK RECIPE — Third-party API key management with encryption.
3
+ *
4
+ * Demonstrates how to issue API tokens (separate from user login tokens)
5
+ * for webhooks and integrations, store the actual key encrypted at rest,
6
+ * and rate-limit the key holder. This is the "give my customer a key to
7
+ * call our API" pattern.
8
+ *
9
+ * Modules used: auth (API tokens), security (encryption + rate limiting),
10
+ * database, error, logger
11
+ * Required env: BLOOM_AUTH_SECRET, BLOOM_SECURITY_ENCRYPTION_KEY, DATABASE_URL
12
+ */
13
+
14
+ import { Router } from 'express';
15
+ import {
16
+ authClass,
17
+ securityClass,
18
+ databaseClass,
19
+ errorClass,
20
+ loggerClass,
21
+ utilClass,
22
+ } from '@bloomneo/appkit';
23
+
24
+ const auth = authClass.get();
25
+ const security = securityClass.get();
26
+ const database = await databaseClass.get();
27
+ const error = errorClass.get();
28
+ const logger = loggerClass.get('api-keys');
29
+ const util = utilClass.get();
30
+
31
+ const router = Router();
32
+
33
+ // ── ISSUE: admin issues a new API key for a third party ────────────
34
+ router.post(
35
+ '/api-keys',
36
+ auth.requireUserRoles(['admin.tenant']),
37
+ error.asyncRoute(async (req, res) => {
38
+ const u = auth.user(req);
39
+ if (!u) throw error.unauthorized();
40
+
41
+ const { name, scopes } = req.body;
42
+ if (!name) throw error.badRequest('name required');
43
+
44
+ // Generate a fresh API token (separate from user login tokens)
45
+ const apiToken = auth.generateApiToken({
46
+ keyId: util.uuid(),
47
+ role: 'service',
48
+ level: scopes?.[0] ?? 'webhook',
49
+ }, '1y'); // long expiry
50
+
51
+ // Store the token ENCRYPTED at rest (so a database leak doesn't
52
+ // expose the plaintext keys)
53
+ const encryptedToken = security.encrypt(apiToken);
54
+
55
+ const record = await database.apiKey.create({
56
+ data: {
57
+ name,
58
+ scopes: JSON.stringify(scopes ?? []),
59
+ encryptedToken,
60
+ createdBy: u.userId,
61
+ tenantId: u.tenantId,
62
+ },
63
+ });
64
+
65
+ logger.info('API key issued', { keyId: record.id, by: u.userId });
66
+
67
+ // Return the plaintext token ONCE — caller is responsible for storing it.
68
+ // The plaintext is never written to disk in our system.
69
+ res.status(201).json({
70
+ id: record.id,
71
+ name: record.name,
72
+ token: apiToken,
73
+ warning: 'Store this token now. It will not be shown again.',
74
+ });
75
+ }),
76
+ );
77
+
78
+ // ── REVOKE: admin revokes a key ─────────────────────────────────────
79
+ router.delete(
80
+ '/api-keys/:id',
81
+ auth.requireUserRoles(['admin.tenant']),
82
+ error.asyncRoute(async (req, res) => {
83
+ const id = Number(req.params.id);
84
+ await database.apiKey.update({
85
+ where: { id },
86
+ data: { revokedAt: new Date() },
87
+ });
88
+ logger.warn('API key revoked', { keyId: id });
89
+ res.json({ revoked: true });
90
+ }),
91
+ );
92
+
93
+ // ── PROTECTED ROUTE: third party hits this with their API token ────
94
+ // Note: requireApiToken (NOT requireLoginToken) — different middleware
95
+ // for different token types.
96
+ router.get(
97
+ '/webhook-data',
98
+ auth.requireApiToken(),
99
+ security.requests(60, 60 * 1000), // 60 requests per minute per token
100
+ error.asyncRoute(async (req, res) => {
101
+ const data = await database.event.findMany({ take: 100 });
102
+ res.json({ data });
103
+ }),
104
+ );
105
+
106
+ export default router;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * COOKBOOK RECIPE — Auth-protected CRUD endpoint with role-based access.
3
+ *
4
+ * Demonstrates the canonical bloomneo backend pattern: 4 modules
5
+ * (auth, database, error, logger) composed into a complete CRUD resource.
6
+ *
7
+ * Modules used: auth, database, error, logger
8
+ * Required env: BLOOM_AUTH_SECRET, DATABASE_URL
9
+ *
10
+ * Drop this file into src/api/features/products/products.route.ts and
11
+ * mount with `app.use('/api/products', router)`.
12
+ */
13
+
14
+ import { Router } from 'express';
15
+ import {
16
+ authClass,
17
+ databaseClass,
18
+ errorClass,
19
+ loggerClass,
20
+ } from '@bloomneo/appkit';
21
+
22
+ const auth = authClass.get();
23
+ const database = await databaseClass.get();
24
+ const error = errorClass.get();
25
+ const logger = loggerClass.get('products');
26
+
27
+ const router = Router();
28
+
29
+ // ── LIST: any logged-in user can list ───────────────────────────────
30
+ router.get(
31
+ '/',
32
+ auth.requireLoginToken(),
33
+ error.asyncRoute(async (req, res) => {
34
+ const products = await database.product.findMany({
35
+ orderBy: { createdAt: 'desc' },
36
+ take: 100,
37
+ });
38
+ res.json({ products });
39
+ }),
40
+ );
41
+
42
+ // ── READ: any logged-in user can read one ───────────────────────────
43
+ router.get(
44
+ '/:id',
45
+ auth.requireLoginToken(),
46
+ error.asyncRoute(async (req, res) => {
47
+ const id = Number(req.params.id);
48
+ if (!Number.isFinite(id)) throw error.badRequest('id must be a number');
49
+
50
+ const product = await database.product.findUnique({ where: { id } });
51
+ if (!product) throw error.notFound('Product not found');
52
+
53
+ res.json({ product });
54
+ }),
55
+ );
56
+
57
+ // ── CREATE: only admins can create ──────────────────────────────────
58
+ router.post(
59
+ '/',
60
+ auth.requireUserRoles(['admin.tenant']),
61
+ error.asyncRoute(async (req, res) => {
62
+ const { name, price } = req.body;
63
+ if (!name) throw error.badRequest('name required');
64
+ if (typeof price !== 'number' || price < 0)
65
+ throw error.badRequest('price must be a positive number');
66
+
67
+ const product = await database.product.create({ data: { name, price } });
68
+ logger.info('Product created', { productId: product.id, by: auth.user(req)?.userId });
69
+
70
+ res.status(201).json({ product });
71
+ }),
72
+ );
73
+
74
+ // ── UPDATE: only admins can update ──────────────────────────────────
75
+ router.patch(
76
+ '/:id',
77
+ auth.requireUserRoles(['admin.tenant']),
78
+ error.asyncRoute(async (req, res) => {
79
+ const id = Number(req.params.id);
80
+ const { name, price } = req.body;
81
+
82
+ const existing = await database.product.findUnique({ where: { id } });
83
+ if (!existing) throw error.notFound('Product not found');
84
+
85
+ const product = await database.product.update({
86
+ where: { id },
87
+ data: { ...(name && { name }), ...(price !== undefined && { price }) },
88
+ });
89
+ logger.info('Product updated', { productId: id, by: auth.user(req)?.userId });
90
+
91
+ res.json({ product });
92
+ }),
93
+ );
94
+
95
+ // ── DELETE: only admins can delete ──────────────────────────────────
96
+ router.delete(
97
+ '/:id',
98
+ auth.requireUserRoles(['admin.tenant']),
99
+ error.asyncRoute(async (req, res) => {
100
+ const id = Number(req.params.id);
101
+
102
+ const existing = await database.product.findUnique({ where: { id } });
103
+ if (!existing) throw error.notFound('Product not found');
104
+
105
+ await database.product.delete({ where: { id } });
106
+ logger.warn('Product deleted', { productId: id, by: auth.user(req)?.userId });
107
+
108
+ res.json({ deleted: true });
109
+ }),
110
+ );
111
+
112
+ export default router;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * COOKBOOK RECIPE — File upload → background processing pipeline.
3
+ *
4
+ * Demonstrates the producer-consumer pattern: upload route stores the file
5
+ * and enqueues a job, a worker process picks up the job and processes the
6
+ * file in the background. Same code works against local disk + memory queue
7
+ * (dev) or S3 + Redis queue (production).
8
+ *
9
+ * Modules used: storage, queue, logger, security, error, auth
10
+ * Required env: BLOOM_AUTH_SECRET
11
+ * Optional env: AWS_S3_BUCKET (cloud storage), REDIS_URL (distributed queue)
12
+ *
13
+ * Note: this example uses multer-style req.file which means you need
14
+ * `import multer from 'multer'` and `app.use(multer().single('file'))`
15
+ * — multer is a peerDependency of @bloomneo/appkit.
16
+ */
17
+
18
+ import { Router, Request, Response } from 'express';
19
+ import {
20
+ storageClass,
21
+ queueClass,
22
+ loggerClass,
23
+ securityClass,
24
+ errorClass,
25
+ authClass,
26
+ } from '@bloomneo/appkit';
27
+
28
+ const storage = storageClass.get();
29
+ const queue = queueClass.get();
30
+ const logger = loggerClass.get('uploads');
31
+ const security = securityClass.get();
32
+ const error = errorClass.get();
33
+ const auth = authClass.get();
34
+
35
+ const router = Router();
36
+
37
+ // ── PRODUCER: upload route ──────────────────────────────────────────
38
+ router.post(
39
+ '/upload',
40
+ auth.requireLoginToken(),
41
+ security.requests(10, 60 * 1000), // 10 uploads / minute / IP
42
+ error.asyncRoute(async (req: Request & { file?: any }, res: Response) => {
43
+ if (!req.file) throw error.badRequest('File required');
44
+
45
+ // Sanitize filename
46
+ const safeName = security.input(req.file.originalname);
47
+ const key = `uploads/${Date.now()}-${safeName}`;
48
+
49
+ // Store the file (auto-detects local vs S3 from env)
50
+ await storage.put(key, req.file.buffer, {
51
+ contentType: req.file.mimetype,
52
+ });
53
+
54
+ // Enqueue background processing
55
+ await queue.add('process-upload', {
56
+ key,
57
+ userId: auth.user(req)?.userId,
58
+ originalName: req.file.originalname,
59
+ mimeType: req.file.mimetype,
60
+ size: req.file.size,
61
+ }, {
62
+ attempts: 3,
63
+ });
64
+
65
+ logger.info('File uploaded, queued for processing', {
66
+ key,
67
+ userId: auth.user(req)?.userId,
68
+ size: req.file.size,
69
+ });
70
+
71
+ res.status(202).json({
72
+ key,
73
+ url: storage.url(key),
74
+ status: 'processing',
75
+ });
76
+ }),
77
+ );
78
+
79
+ // ── CONSUMER: background worker (run in same process or separate worker) ──
80
+ queue.process('process-upload', async (data) => {
81
+ const { key, userId, originalName } = data;
82
+ const workerLogger = loggerClass.get('upload-worker');
83
+
84
+ try {
85
+ workerLogger.info('Processing upload', { key, userId });
86
+
87
+ // Fetch the file
88
+ const buffer = await storage.get(key);
89
+
90
+ // ...do something with it (resize, OCR, virus scan, transcode, etc.)
91
+ // const processed = await processImage(buffer);
92
+
93
+ // Store the processed result
94
+ const processedKey = key.replace(/(\.[^.]+)$/, '-processed$1');
95
+ await storage.put(processedKey, buffer);
96
+
97
+ workerLogger.info('Upload processed', {
98
+ original: key,
99
+ processed: processedKey,
100
+ userId,
101
+ });
102
+
103
+ return { processedKey, originalName };
104
+ } catch (err) {
105
+ workerLogger.error('Upload processing failed', {
106
+ key,
107
+ error: (err as Error).message,
108
+ });
109
+ throw err; // queue will retry per the attempts: 3 setting above
110
+ }
111
+ });
112
+
113
+ export default router;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * COOKBOOK RECIPE — Multi-tenant SaaS with auto-filtering and per-tenant cache.
3
+ *
4
+ * Demonstrates the unique bloomneo multi-tenant pattern: every database
5
+ * query is automatically filtered by the current user's tenant_id, and
6
+ * the cache namespace is derived from the tenant id so cache entries
7
+ * never leak between tenants.
8
+ *
9
+ * Modules used: auth, database, cache, security, error
10
+ * Required env: BLOOM_AUTH_SECRET, DATABASE_URL, BLOOM_DB_TENANT=auto
11
+ * Optional env: REDIS_URL (for distributed cache)
12
+ */
13
+
14
+ import { Router } from 'express';
15
+ import {
16
+ authClass,
17
+ databaseClass,
18
+ cacheClass,
19
+ securityClass,
20
+ errorClass,
21
+ } from '@bloomneo/appkit';
22
+
23
+ const auth = authClass.get();
24
+ const security = securityClass.get();
25
+ const error = errorClass.get();
26
+
27
+ // IMPORTANT: databaseClass.get() returns a tenant-aware client when
28
+ // BLOOM_DB_TENANT=auto. Every query below is auto-filtered.
29
+ const database = await databaseClass.get();
30
+
31
+ const router = Router();
32
+
33
+ // ── Per-tenant cached list ──────────────────────────────────────────
34
+ router.get(
35
+ '/users',
36
+ auth.requireLoginToken(),
37
+ security.requests(100, 15 * 60 * 1000), // per-IP rate limit
38
+ error.asyncRoute(async (req, res) => {
39
+ const user = auth.user(req);
40
+ if (!user) throw error.unauthorized();
41
+
42
+ // Cache key includes tenantId so different tenants don't share cache.
43
+ const tenantCache = cacheClass.get(`tenant:${user.tenantId}`);
44
+
45
+ const users = await tenantCache.getOrSet(
46
+ 'users:list',
47
+ () => database.user.findMany({ orderBy: { createdAt: 'desc' } }),
48
+ 300, // 5 minutes
49
+ );
50
+
51
+ res.json({ users, tenant: user.tenantId });
52
+ }),
53
+ );
54
+
55
+ // ── Per-tenant cache invalidation on write ──────────────────────────
56
+ router.post(
57
+ '/users',
58
+ auth.requireUserRoles(['admin.tenant']),
59
+ error.asyncRoute(async (req, res) => {
60
+ const u = auth.user(req);
61
+ if (!u) throw error.unauthorized();
62
+
63
+ const newUser = await database.user.create({ data: req.body });
64
+
65
+ // Invalidate the cached list so the next read sees the new user.
66
+ const tenantCache = cacheClass.get(`tenant:${u.tenantId}`);
67
+ await tenantCache.delete('users:list');
68
+
69
+ res.status(201).json({ user: newUser });
70
+ }),
71
+ );
72
+
73
+ // ── Cross-tenant analytics (admin.system only — uses unfiltered client) ──
74
+ router.get(
75
+ '/admin/analytics',
76
+ auth.requireUserRoles(['admin.system']),
77
+ error.asyncRoute(async (req, res) => {
78
+ const dbAll = await databaseClass.getTenants();
79
+ const stats = await dbAll.user.groupBy({
80
+ by: ['tenant_id'],
81
+ _count: { _all: true },
82
+ });
83
+ res.json({ tenants: stats });
84
+ }),
85
+ );
86
+
87
+ export default router;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * COOKBOOK RECIPE — Real-time chat with pub/sub events.
3
+ *
4
+ * Demonstrates the eventClass pattern for real-time features. Same code
5
+ * works in single-process mode (memory backend) or distributed mode
6
+ * (Redis pub/sub when REDIS_URL is set).
7
+ *
8
+ * Pair with a WebSocket library (Socket.IO, ws, etc.) for the actual
9
+ * client transport. This file shows the bloomneo backend half — the
10
+ * WebSocket layer just calls events.emit() / events.on() under the hood.
11
+ *
12
+ * Modules used: event, auth, database, error
13
+ * Required env: BLOOM_AUTH_SECRET, DATABASE_URL
14
+ * Optional env: REDIS_URL (for cross-process event distribution)
15
+ */
16
+
17
+ import {
18
+ eventClass,
19
+ authClass,
20
+ databaseClass,
21
+ errorClass,
22
+ } from '@bloomneo/appkit';
23
+
24
+ // Namespaced events: 'chat' isolates these events from other event streams
25
+ // in the same app.
26
+ const events = eventClass.get('chat');
27
+ const auth = authClass.get();
28
+ const database = await databaseClass.get();
29
+ const error = errorClass.get();
30
+
31
+ // ── EVENT HANDLERS (subscribe at startup) ───────────────────────────
32
+
33
+ // User connects (called from your WebSocket onConnection handler)
34
+ events.on('user.connected', async (data: { userId: number; socketId: string }) => {
35
+ const { userId, socketId } = data;
36
+
37
+ // Look up the user's rooms from the database
38
+ const memberships = await database.roomMember.findMany({
39
+ where: { userId },
40
+ select: { roomId: true },
41
+ });
42
+
43
+ // Tell the WebSocket layer to join this socket to its rooms
44
+ await events.emit('socket.join-rooms', {
45
+ socketId,
46
+ rooms: [
47
+ `user:${userId}`,
48
+ ...memberships.map((m) => `room:${m.roomId}`),
49
+ ],
50
+ });
51
+ });
52
+
53
+ // User sends a message
54
+ events.on('message.send', async (data: {
55
+ userId: number;
56
+ roomId: number;
57
+ content: string;
58
+ }) => {
59
+ // Persist the message
60
+ const message = await database.message.create({
61
+ data: {
62
+ content: data.content,
63
+ userId: data.userId,
64
+ roomId: data.roomId,
65
+ },
66
+ include: { user: { select: { id: true, name: true, avatar: true } } },
67
+ });
68
+
69
+ // Broadcast to everyone in the room
70
+ await events.emit('message.broadcast', {
71
+ roomId: data.roomId,
72
+ message: {
73
+ id: message.id,
74
+ content: message.content,
75
+ user: message.user,
76
+ timestamp: message.createdAt.toISOString(),
77
+ },
78
+ });
79
+ });
80
+
81
+ // Wildcard subscriber for analytics / logging
82
+ events.on('*', async (eventName: string, data: any) => {
83
+ // Log every chat event for analytics
84
+ // (in production, send to a queue instead of logging inline)
85
+ console.log(`[chat] ${eventName}`, JSON.stringify(data));
86
+ });
87
+
88
+ // ── REST API: send message via HTTP (alternative to WebSocket) ──────
89
+ import { Router } from 'express';
90
+ const router = Router();
91
+
92
+ router.post(
93
+ '/rooms/:roomId/messages',
94
+ auth.requireLoginToken(),
95
+ error.asyncRoute(async (req, res) => {
96
+ const u = auth.user(req);
97
+ if (!u) throw error.unauthorized();
98
+
99
+ const roomId = Number(req.params.roomId);
100
+ const { content } = req.body;
101
+ if (!content) throw error.badRequest('content required');
102
+
103
+ // Verify membership
104
+ const membership = await database.roomMember.findFirst({
105
+ where: { userId: u.userId, roomId },
106
+ });
107
+ if (!membership) throw error.forbidden('Not a member of this room');
108
+
109
+ // Emit the event — WebSocket layer + persistence both happen via the
110
+ // events.on('message.send') handler above.
111
+ await events.emit('message.send', {
112
+ userId: u.userId,
113
+ roomId,
114
+ content,
115
+ });
116
+
117
+ res.status(202).json({ queued: true });
118
+ }),
119
+ );
120
+
121
+ export default router;
@@ -129,14 +129,31 @@ export declare class AuthenticationClass {
129
129
  */
130
130
  hasRole(userRoleLevel: string, requiredRoleLevel: string): boolean;
131
131
  /**
132
- * Checks if user has specific permission with automatic action inheritance
132
+ * Checks if user has specific permission with automatic action inheritance.
133
+ *
134
+ * Permission resolution rule (REPLACEMENT, not additive):
135
+ * - If `user.permissions` is set (an array, even empty), it is the COMPLETE
136
+ * permission set for the user. Role defaults are NOT consulted.
137
+ * - If `user.permissions` is undefined / null, the role.level's default
138
+ * permissions from the configured RolePermissionConfig are used.
139
+ *
140
+ * This matches AWS IAM, Casbin, OPA, Auth0 RBAC, and every mainstream
141
+ * permission system: explicit permissions are the truth, defaults are the
142
+ * fallback. To downgrade a user below their role's defaults, pass an
143
+ * explicit `permissions: [...]` array (even an empty `[]` is valid — it
144
+ * means "no permissions despite the role").
145
+ *
146
+ * Action inheritance rule (within a scope):
147
+ * - `manage:<scope>` includes view, create, edit, delete for that scope
148
+ * - No upward inheritance: `edit:tenant` does NOT grant `manage:tenant`
149
+ *
133
150
  * @llm-rule WHEN: Checking fine-grained permissions for specific actions
134
- * @llm-rule AVOID: Hardcoding permission checks - this handles action inheritance
151
+ * @llm-rule AVOID: Hardcoding permission checks - this handles inheritance
135
152
  * @llm-rule NOTE: 'manage:scope' includes ALL other actions for that scope
136
- * @llm-rule NOTE: PERMISSION INHERITANCE EXAMPLES:
153
+ * @llm-rule NOTE: Explicit user.permissions REPLACES role defaults (not additive)
137
154
  * @llm-rule NOTE: If user has 'manage:tenant' → can('edit:tenant') returns TRUE
138
- * @llm-rule NOTE: If user has 'manage:tenant' → can('view:tenant') returns TRUE
139
155
  * @llm-rule NOTE: If user has 'edit:tenant' → can('manage:tenant') returns FALSE
156
+ * @llm-rule NOTE: To downgrade a user, pass permissions: [] (empty array)
140
157
  * @llm-rule NOTE: Actions hierarchy: manage > delete > edit > create > view
141
158
  */
142
159
  can(user: JwtPayload, permission: string): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/auth/auth.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/auth/auth.ts"],"names":[],"mappings":"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"}