@bloomneo/appkit 1.5.1 → 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.
- package/AGENTS.md +195 -0
- package/CHANGELOG.md +253 -0
- package/README.md +147 -799
- package/bin/commands/generate.js +7 -7
- package/cookbook/README.md +26 -0
- package/cookbook/api-key-service.ts +106 -0
- package/cookbook/auth-protected-crud.ts +112 -0
- package/cookbook/file-upload-pipeline.ts +113 -0
- package/cookbook/multi-tenant-saas.ts +87 -0
- package/cookbook/real-time-chat.ts +121 -0
- package/dist/auth/auth.d.ts +21 -4
- package/dist/auth/auth.d.ts.map +1 -1
- package/dist/auth/auth.js +56 -44
- package/dist/auth/auth.js.map +1 -1
- package/dist/auth/defaults.d.ts +1 -1
- package/dist/auth/defaults.js +35 -35
- package/dist/cache/cache.d.ts +29 -6
- package/dist/cache/cache.d.ts.map +1 -1
- package/dist/cache/cache.js +72 -44
- package/dist/cache/cache.js.map +1 -1
- package/dist/cache/defaults.js +25 -25
- package/dist/cache/index.d.ts +19 -10
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +21 -18
- package/dist/cache/index.js.map +1 -1
- package/dist/config/defaults.d.ts +1 -1
- package/dist/config/defaults.js +8 -8
- package/dist/config/index.d.ts +3 -3
- package/dist/config/index.js +4 -4
- package/dist/database/adapters/mongoose.js +2 -2
- package/dist/database/adapters/prisma.js +2 -2
- package/dist/database/defaults.d.ts +1 -1
- package/dist/database/defaults.js +4 -4
- package/dist/database/index.js +2 -2
- package/dist/database/index.js.map +1 -1
- package/dist/email/defaults.js +20 -20
- package/dist/error/defaults.d.ts +1 -1
- package/dist/error/defaults.js +12 -12
- package/dist/error/error.d.ts +12 -0
- package/dist/error/error.d.ts.map +1 -1
- package/dist/error/error.js +19 -0
- package/dist/error/error.js.map +1 -1
- package/dist/error/index.d.ts +14 -3
- package/dist/error/index.d.ts.map +1 -1
- package/dist/error/index.js +14 -3
- package/dist/error/index.js.map +1 -1
- package/dist/event/defaults.js +30 -30
- package/dist/logger/defaults.d.ts +1 -1
- package/dist/logger/defaults.js +40 -40
- package/dist/logger/index.d.ts +1 -0
- package/dist/logger/index.d.ts.map +1 -1
- package/dist/logger/index.js.map +1 -1
- package/dist/logger/logger.d.ts +8 -0
- package/dist/logger/logger.d.ts.map +1 -1
- package/dist/logger/logger.js +13 -3
- package/dist/logger/logger.js.map +1 -1
- package/dist/logger/transports/console.js +1 -1
- package/dist/logger/transports/http.d.ts +1 -1
- package/dist/logger/transports/http.js +1 -1
- package/dist/logger/transports/webhook.d.ts +1 -1
- package/dist/logger/transports/webhook.js +1 -1
- package/dist/queue/defaults.d.ts +2 -2
- package/dist/queue/defaults.js +38 -38
- package/dist/security/defaults.d.ts +1 -1
- package/dist/security/defaults.js +29 -29
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +3 -3
- package/dist/security/security.d.ts +1 -1
- package/dist/security/security.js +4 -4
- package/dist/storage/defaults.js +19 -19
- package/dist/util/defaults.d.ts +1 -1
- package/dist/util/defaults.js +34 -34
- package/dist/util/env.d.ts +35 -0
- package/dist/util/env.d.ts.map +1 -0
- package/dist/util/env.js +50 -0
- package/dist/util/env.js.map +1 -0
- package/dist/util/errors.d.ts +52 -0
- package/dist/util/errors.d.ts.map +1 -0
- package/dist/util/errors.js +82 -0
- package/dist/util/errors.js.map +1 -0
- package/examples/.env.example +80 -0
- package/examples/README.md +16 -0
- package/examples/auth.ts +228 -0
- package/examples/cache.ts +36 -0
- package/examples/config.ts +45 -0
- package/examples/database.ts +69 -0
- package/examples/email.ts +53 -0
- package/examples/error.ts +50 -0
- package/examples/event.ts +42 -0
- package/examples/logger.ts +41 -0
- package/examples/queue.ts +58 -0
- package/examples/security.ts +46 -0
- package/examples/storage.ts +44 -0
- package/examples/util.ts +47 -0
- package/llms.txt +591 -0
- package/package.json +19 -10
- package/src/auth/README.md +850 -0
- package/src/cache/README.md +756 -0
- package/src/config/README.md +604 -0
- package/src/database/README.md +818 -0
- package/src/email/README.md +759 -0
- package/src/error/README.md +660 -0
- package/src/event/README.md +729 -0
- package/src/logger/README.md +435 -0
- package/src/queue/README.md +851 -0
- package/src/security/README.md +612 -0
- package/src/storage/README.md +1008 -0
- package/src/util/README.md +955 -0
- package/bin/templates/backend/docs/APPKIT_CLI.md +0 -507
- package/bin/templates/backend/docs/APPKIT_COMMENTS_GUIDELINES.md +0 -61
- package/bin/templates/backend/docs/APPKIT_LLM_GUIDE.md +0 -2539
package/examples/auth.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CANONICAL PATTERN — JWT auth with role-based middleware.
|
|
3
|
+
*
|
|
4
|
+
* Verified against src/auth/README.md (lines 426-479: API Reference)
|
|
5
|
+
* and src/auth/auth.ts (real public method signatures).
|
|
6
|
+
*
|
|
7
|
+
* Two token types: LOGIN tokens for users, API tokens for services.
|
|
8
|
+
* Middleware chains: `requireLoginToken()` FIRST, then `requireUserRoles()`
|
|
9
|
+
* — never the reverse, never standalone, never with API tokens.
|
|
10
|
+
*
|
|
11
|
+
* Required env: BLOOM_AUTH_SECRET (min 32 chars)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { authClass } from '@bloomneo/appkit/auth';
|
|
15
|
+
import type {
|
|
16
|
+
ExpressRequest,
|
|
17
|
+
ExpressResponse,
|
|
18
|
+
ExpressMiddleware,
|
|
19
|
+
} from '@bloomneo/appkit/auth';
|
|
20
|
+
|
|
21
|
+
const auth = authClass.get();
|
|
22
|
+
|
|
23
|
+
// ── Token generation ────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
// Login token: for human users (web/mobile login). 7-day expiry typical.
|
|
26
|
+
const loginToken: string = auth.generateLoginToken(
|
|
27
|
+
{
|
|
28
|
+
userId: 123,
|
|
29
|
+
role: 'user',
|
|
30
|
+
level: 'basic',
|
|
31
|
+
},
|
|
32
|
+
'7d',
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// API token: for services / webhooks / integrations. 1-year expiry typical.
|
|
36
|
+
//
|
|
37
|
+
// IMPORTANT: the role.level you pass MUST exist in the configured role
|
|
38
|
+
// hierarchy or generateApiToken throws "Invalid role.level". The default
|
|
39
|
+
// hierarchy ships with `user.basic` → `admin.system` (9 user roles, no
|
|
40
|
+
// service roles). For service tokens you have two options:
|
|
41
|
+
//
|
|
42
|
+
// 1. Use one of the default roles (e.g. `admin.system`) — what we do here.
|
|
43
|
+
// 2. Register custom service roles via the BLOOM_AUTH_ROLES env var, e.g.
|
|
44
|
+
// BLOOM_AUTH_ROLES=user.basic:1,...,service.webhook:10
|
|
45
|
+
// and then pass `role: 'service', level: 'webhook'` here.
|
|
46
|
+
//
|
|
47
|
+
// Option 1 is simpler when the API token holder needs full system access.
|
|
48
|
+
// Option 2 is correct when you want narrower scopes for different services.
|
|
49
|
+
const apiToken: string = auth.generateApiToken(
|
|
50
|
+
{
|
|
51
|
+
keyId: 'webhook_payment_service',
|
|
52
|
+
role: 'admin',
|
|
53
|
+
level: 'system',
|
|
54
|
+
},
|
|
55
|
+
'1y',
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// ── Middleware (use these in route definitions) ─────────────────────
|
|
59
|
+
|
|
60
|
+
// 1. Authenticate ANY logged-in user
|
|
61
|
+
const protectAuthenticated: ExpressMiddleware = auth.requireLoginToken();
|
|
62
|
+
|
|
63
|
+
// 2. Require specific user role (chained AFTER requireLoginToken)
|
|
64
|
+
const requireAdmin: ExpressMiddleware = auth.requireUserRoles(['admin.tenant']);
|
|
65
|
+
|
|
66
|
+
// 3. Require any of multiple roles (OR semantics)
|
|
67
|
+
const requireAdminOrOrg: ExpressMiddleware = auth.requireUserRoles([
|
|
68
|
+
'admin.tenant',
|
|
69
|
+
'admin.org',
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
// 4. Require specific permissions (AND semantics across the array)
|
|
73
|
+
const requireUserManagement: ExpressMiddleware = auth.requireUserPermissions([
|
|
74
|
+
'manage:users',
|
|
75
|
+
'edit:tenant',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// 5. API token authentication (services only — DO NOT chain with requireUserRoles)
|
|
79
|
+
const protectApi: ExpressMiddleware = auth.requireApiToken();
|
|
80
|
+
|
|
81
|
+
// ── Route handlers (called by Express after middleware passes) ──────
|
|
82
|
+
|
|
83
|
+
// Login handler: bcrypt check + token issue
|
|
84
|
+
async function loginHandler(req: ExpressRequest, res: ExpressResponse) {
|
|
85
|
+
const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
|
|
86
|
+
|
|
87
|
+
// Look up the user from your database. Placeholder.
|
|
88
|
+
const user = {
|
|
89
|
+
id: 1,
|
|
90
|
+
email,
|
|
91
|
+
passwordHash: '$2b$10$placeholder',
|
|
92
|
+
role: 'user',
|
|
93
|
+
level: 'basic',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const valid = await auth.comparePassword(password ?? '', user.passwordHash);
|
|
97
|
+
if (!valid) {
|
|
98
|
+
res.status?.(401).json?.({ error: 'Invalid credentials' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const token = auth.generateLoginToken({
|
|
103
|
+
userId: user.id,
|
|
104
|
+
role: user.role,
|
|
105
|
+
level: user.level,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
res.json?.({ token });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Registration handler: bcrypt hash + token issue
|
|
112
|
+
async function registerHandler(req: ExpressRequest, res: ExpressResponse) {
|
|
113
|
+
const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
|
|
114
|
+
const hashedPassword = await auth.hashPassword(password ?? '');
|
|
115
|
+
// Save { email, password: hashedPassword } to your database. Placeholder.
|
|
116
|
+
void hashedPassword;
|
|
117
|
+
void email;
|
|
118
|
+
const newUserId = 42;
|
|
119
|
+
|
|
120
|
+
const token = auth.generateLoginToken({
|
|
121
|
+
userId: newUserId,
|
|
122
|
+
role: 'user',
|
|
123
|
+
level: 'basic',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
res.json?.({ token });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Authenticated handler: extract user safely from req
|
|
130
|
+
function profileHandler(req: ExpressRequest, res: ExpressResponse) {
|
|
131
|
+
const user = auth.user(req); // null-safe; non-null after requireLoginToken
|
|
132
|
+
if (!user) {
|
|
133
|
+
res.status?.(401).json?.({ error: 'Not authenticated' });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
res.json?.({ user });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Programmatic role hierarchy check (outside middleware) ──────────
|
|
140
|
+
|
|
141
|
+
const orgIncludesTenant: boolean = auth.hasRole('admin.org', 'admin.tenant'); // true
|
|
142
|
+
const userIsAdmin: boolean = auth.hasRole('user.basic', 'admin.tenant'); // false
|
|
143
|
+
|
|
144
|
+
// ── Verify a token manually (outside middleware) ────────────────────
|
|
145
|
+
//
|
|
146
|
+
// `verifyToken` is what the middleware uses internally. You normally don't
|
|
147
|
+
// call it yourself — `requireLoginToken()` and `requireApiToken()` handle
|
|
148
|
+
// it for you. Use this only when you need to validate a token in a
|
|
149
|
+
// non-Express context (e.g. WebSocket auth, queue worker auth, scheduled
|
|
150
|
+
// task auth). Throws on bad token; never returns null.
|
|
151
|
+
function verifyTokenManually(rawToken: string) {
|
|
152
|
+
try {
|
|
153
|
+
const payload = auth.verifyToken(rawToken);
|
|
154
|
+
// payload.userId is set for login tokens; payload.keyId is set for API tokens.
|
|
155
|
+
// payload.type tells you which: 'login' or 'api_key'.
|
|
156
|
+
return payload;
|
|
157
|
+
} catch (e) {
|
|
158
|
+
// Possible messages: 'Token has expired', 'Invalid token',
|
|
159
|
+
// 'Token must be a string', 'Token verification failed: ...'
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Permission check with inheritance (auth.can) ────────────────────
|
|
165
|
+
//
|
|
166
|
+
// `can` checks fine-grained permissions on a user payload. Permission format
|
|
167
|
+
// is `action:scope` (e.g. 'edit:tenant', 'manage:users'). Inheritance rule:
|
|
168
|
+
// `manage:scope` automatically grants `view:scope`, `edit:scope`,
|
|
169
|
+
// `delete:scope`, etc. for the same scope.
|
|
170
|
+
//
|
|
171
|
+
// Use this inside a route handler AFTER `requireLoginToken()` middleware
|
|
172
|
+
// has validated the user and attached the payload.
|
|
173
|
+
function permissionCheckHandler(req: ExpressRequest, res: ExpressResponse) {
|
|
174
|
+
const user = auth.user(req);
|
|
175
|
+
if (!user) {
|
|
176
|
+
res.status?.(401).json?.({ error: 'Not authenticated' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check if the user can edit their tenant. Returns true if the user has
|
|
181
|
+
// 'edit:tenant' OR 'manage:tenant' (manage inherits all actions).
|
|
182
|
+
const canEdit: boolean = auth.can(user, 'edit:tenant');
|
|
183
|
+
if (!canEdit) {
|
|
184
|
+
res.status?.(403).json?.({ error: 'Cannot edit tenant' });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
res.json?.({ allowed: true });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Express wiring (commented — uncomment in your app) ──────────────
|
|
192
|
+
//
|
|
193
|
+
// import express from 'express';
|
|
194
|
+
// const app = express();
|
|
195
|
+
//
|
|
196
|
+
// app.post('/login', loginHandler);
|
|
197
|
+
// app.post('/register', registerHandler);
|
|
198
|
+
//
|
|
199
|
+
// // User route: login + role required
|
|
200
|
+
// app.get(
|
|
201
|
+
// '/admin/users',
|
|
202
|
+
// auth.requireLoginToken(),
|
|
203
|
+
// auth.requireUserRoles(['admin.tenant']),
|
|
204
|
+
// profileHandler,
|
|
205
|
+
// );
|
|
206
|
+
//
|
|
207
|
+
// // API route: API token only (no user roles)
|
|
208
|
+
// app.post('/webhook/payment', auth.requireApiToken(), (req, res) => {
|
|
209
|
+
// res.json({ ok: true });
|
|
210
|
+
// });
|
|
211
|
+
|
|
212
|
+
export {
|
|
213
|
+
auth,
|
|
214
|
+
loginToken,
|
|
215
|
+
apiToken,
|
|
216
|
+
protectAuthenticated,
|
|
217
|
+
requireAdmin,
|
|
218
|
+
requireAdminOrOrg,
|
|
219
|
+
requireUserManagement,
|
|
220
|
+
protectApi,
|
|
221
|
+
verifyTokenManually,
|
|
222
|
+
permissionCheckHandler,
|
|
223
|
+
loginHandler,
|
|
224
|
+
registerHandler,
|
|
225
|
+
profileHandler,
|
|
226
|
+
orgIncludesTenant,
|
|
227
|
+
userIsAdmin,
|
|
228
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CANONICAL PATTERN — cache.getOrSet for "fetch once, cache the result".
|
|
3
|
+
*
|
|
4
|
+
* Copy this file when you need to cache anything. The getOrSet pattern is
|
|
5
|
+
* the right answer 90% of the time — never write the cache-check-then-fetch
|
|
6
|
+
* pattern manually.
|
|
7
|
+
*
|
|
8
|
+
* Set REDIS_URL in .env to upgrade from in-process memory to distributed
|
|
9
|
+
* Redis cache. Same code works for both — no changes needed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { cacheClass, databaseClass, errorClass } from '@bloomneo/appkit';
|
|
13
|
+
|
|
14
|
+
const error = errorClass.get();
|
|
15
|
+
const database = await databaseClass.get();
|
|
16
|
+
|
|
17
|
+
// Custom namespace isolates this cache from others (default is 'app').
|
|
18
|
+
const cache = cacheClass.get('users');
|
|
19
|
+
|
|
20
|
+
// ── Cached database query ─────────────────────────────────────────────
|
|
21
|
+
export const listProductsRoute = error.asyncRoute(async (req, res) => {
|
|
22
|
+
const products = await cache.getOrSet(
|
|
23
|
+
'products:list',
|
|
24
|
+
() => database.product.findMany({ where: { published: true } }),
|
|
25
|
+
300, // 5 minutes
|
|
26
|
+
);
|
|
27
|
+
res.json({ products });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── Manual operations (use sparingly — getOrSet is preferred) ────────
|
|
31
|
+
export async function manualCacheExample() {
|
|
32
|
+
await cache.set('foo', 'bar', 60); // 1 minute TTL
|
|
33
|
+
const v = await cache.get('foo'); // → 'bar'
|
|
34
|
+
const exists = v !== null; // presence check: get() returns null on miss
|
|
35
|
+
await cache.delete('foo'); // delete() — not del()
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CANONICAL PATTERN — type-safe environment variable access.
|
|
3
|
+
*
|
|
4
|
+
* Copy this file when you need to read configuration. Always go through
|
|
5
|
+
* configClass.get() instead of touching process.env directly — config
|
|
6
|
+
* validates types, provides defaults, and supports the BLOOM_* prefix
|
|
7
|
+
* convention.
|
|
8
|
+
*
|
|
9
|
+
* Read AGENTS.md for the full env var reference.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { configClass } from '@bloomneo/appkit';
|
|
13
|
+
|
|
14
|
+
const config = configClass.get();
|
|
15
|
+
|
|
16
|
+
// ── Read string config (with default) ───────────────────────────────
|
|
17
|
+
const apiHost = config.get('api.host', 'localhost');
|
|
18
|
+
const dbUrl = config.get('database.url'); // throws if not set
|
|
19
|
+
|
|
20
|
+
// ── Read typed config ───────────────────────────────────────────────
|
|
21
|
+
// config.get() returns the value as-is. Cast to number/boolean yourself.
|
|
22
|
+
const port = Number(config.get('api.port') ?? 3000);
|
|
23
|
+
const debugMode = config.get('app.debug') === 'true';
|
|
24
|
+
|
|
25
|
+
// ── Environment detection ───────────────────────────────────────────
|
|
26
|
+
// isDevelopment() / isProduction() / isTest() live on configClass, NOT on the
|
|
27
|
+
// instance returned by configClass.get(). Always call them via configClass.*.
|
|
28
|
+
if (configClass.isDevelopment()) {
|
|
29
|
+
console.log('Running in dev mode');
|
|
30
|
+
}
|
|
31
|
+
if (configClass.isProduction()) {
|
|
32
|
+
// tighter security defaults, etc.
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Validate required env vars at startup ───────────────────────────
|
|
36
|
+
// Call this once in your server bootstrap. Throws with a clear error message
|
|
37
|
+
// listing every missing variable.
|
|
38
|
+
config.validateRequired([
|
|
39
|
+
'BLOOM_AUTH_SECRET',
|
|
40
|
+
'DATABASE_URL',
|
|
41
|
+
'BLOOM_SECURITY_CSRF_SECRET',
|
|
42
|
+
'BLOOM_SECURITY_ENCRYPTION_KEY',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
export { config, apiHost, dbUrl, port, debugMode };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CANONICAL PATTERN — multi-tenant database access.
|
|
3
|
+
*
|
|
4
|
+
* Verified against src/database/README.md (Core API + LLM Guidelines sections)
|
|
5
|
+
* and src/database/index.ts.
|
|
6
|
+
*
|
|
7
|
+
* Three core methods:
|
|
8
|
+
* • databaseClass.get() → tenant-aware client (single user mode)
|
|
9
|
+
* • databaseClass.getTenants() → unfiltered client (admin / cross-tenant)
|
|
10
|
+
* • databaseClass.org('name').get() / .getTenants() → per-org databases
|
|
11
|
+
*
|
|
12
|
+
* Required env: DATABASE_URL
|
|
13
|
+
* Optional env: BLOOM_DB_TENANT=auto (enables multi-tenant filtering)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { databaseClass } from '@bloomneo/appkit/database';
|
|
17
|
+
|
|
18
|
+
// ── Normal user access (auto-filtered by tenant when BLOOM_DB_TENANT=auto) ──
|
|
19
|
+
async function listUsers() {
|
|
20
|
+
const database = await databaseClass.get();
|
|
21
|
+
// Returns only rows belonging to the current tenant.
|
|
22
|
+
// The shape of `database.user` matches your Prisma / Mongoose model.
|
|
23
|
+
// Placeholder query — replace with your actual model.
|
|
24
|
+
const users = await (database as any).user?.findMany?.() ?? [];
|
|
25
|
+
return users;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Admin: cross-tenant query (unfiltered) ──────────────────────────
|
|
29
|
+
async function listAllUsersAcrossTenants() {
|
|
30
|
+
const dbTenants = await databaseClass.getTenants();
|
|
31
|
+
const allUsers = await (dbTenants as any).user?.findMany?.() ?? [];
|
|
32
|
+
return allUsers;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Admin: per-tenant analytics ─────────────────────────────────────
|
|
36
|
+
async function tenantAnalytics() {
|
|
37
|
+
const dbTenants = await databaseClass.getTenants();
|
|
38
|
+
const stats = await (dbTenants as any).user?.groupBy?.({
|
|
39
|
+
by: ['tenant_id'],
|
|
40
|
+
_count: { _all: true },
|
|
41
|
+
}) ?? [];
|
|
42
|
+
return stats;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Multi-org: per-organization database ────────────────────────────
|
|
46
|
+
// Set ORG_ACME=postgresql://acme.aws.com/prod in .env, then:
|
|
47
|
+
async function listAcmeUsers() {
|
|
48
|
+
const acmedatabase = await databaseClass.org('acme').get();
|
|
49
|
+
const users = await (acmedatabase as any).user?.findMany?.() ?? [];
|
|
50
|
+
return users;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Multi-org admin: all tenants in a single org ────────────────────
|
|
54
|
+
async function acmeTenantStats() {
|
|
55
|
+
const acmeDbTenants = await databaseClass.org('acme').getTenants();
|
|
56
|
+
const stats = await (acmeDbTenants as any).user?.groupBy?.({
|
|
57
|
+
by: ['tenant_id'],
|
|
58
|
+
_count: { _all: true },
|
|
59
|
+
}) ?? [];
|
|
60
|
+
return stats;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export {
|
|
64
|
+
listUsers,
|
|
65
|
+
listAllUsersAcrossTenants,
|
|
66
|
+
tenantAnalytics,
|
|
67
|
+
listAcmeUsers,
|
|
68
|
+
acmeTenantStats,
|
|
69
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CANONICAL PATTERN — send email with auto-scaling provider.
|
|
3
|
+
*
|
|
4
|
+
* Copy this file when you need to send email. Default is console (logs to
|
|
5
|
+
* terminal — perfect for development). Set SMTP_HOST + SMTP_USER + SMTP_PASS
|
|
6
|
+
* to use SMTP. Set RESEND_API_KEY to use Resend.
|
|
7
|
+
*
|
|
8
|
+
* Same code works for all three providers — no changes needed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { emailClass, errorClass } from '@bloomneo/appkit';
|
|
12
|
+
|
|
13
|
+
const email = emailClass.get();
|
|
14
|
+
const error = errorClass.get();
|
|
15
|
+
|
|
16
|
+
// ── Plain text email ────────────────────────────────────────────────
|
|
17
|
+
export async function sendWelcomeEmail(toAddress: string, userName: string) {
|
|
18
|
+
await email.send({
|
|
19
|
+
to: toAddress,
|
|
20
|
+
subject: 'Welcome to MyApp',
|
|
21
|
+
text: `Hi ${userName},\n\nThanks for signing up!\n\n— The Team`,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── HTML email with plain-text fallback ─────────────────────────────
|
|
26
|
+
export async function sendInvoiceEmail(toAddress: string, invoiceUrl: string) {
|
|
27
|
+
await email.send({
|
|
28
|
+
to: toAddress,
|
|
29
|
+
subject: 'Your invoice is ready',
|
|
30
|
+
text: `Your invoice: ${invoiceUrl}`,
|
|
31
|
+
html: `<p>Your invoice: <a href="${invoiceUrl}">${invoiceUrl}</a></p>`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Templated email ─────────────────────────────────────────────────
|
|
36
|
+
// EmailData (used by email.send()) only accepts: to, from, subject, text,
|
|
37
|
+
// html, attachments, replyTo, cc, bcc — it has no template/data fields.
|
|
38
|
+
// Use email.sendTemplate() for template-driven emails.
|
|
39
|
+
export async function sendPasswordResetEmail(toAddress: string, resetLink: string) {
|
|
40
|
+
await email.sendTemplate('password-reset', {
|
|
41
|
+
to: toAddress,
|
|
42
|
+
resetLink,
|
|
43
|
+
expiresIn: '1 hour',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Inside a route handler ──────────────────────────────────────────
|
|
48
|
+
export const sendNotificationRoute = error.asyncRoute(async (req, res) => {
|
|
49
|
+
const { to, message } = req.body;
|
|
50
|
+
if (!to || !message) throw error.badRequest('to and message required');
|
|
51
|
+
await email.send({ to, subject: 'Notification', text: message });
|
|
52
|
+
res.json({ sent: true });
|
|
53
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CANONICAL PATTERN — semantic HTTP errors + centralized middleware.
|
|
3
|
+
*
|
|
4
|
+
* Copy this file when you need error handling in routes. The pattern:
|
|
5
|
+
* 1. Wrap async route handlers in error.asyncRoute(...)
|
|
6
|
+
* 2. Throw semantic errors (badRequest, unauthorized, notFound, etc.)
|
|
7
|
+
* — never `throw new Error(...)` in route handlers
|
|
8
|
+
* 3. Mount error.handleErrors() as the LAST middleware in the Express stack
|
|
9
|
+
*
|
|
10
|
+
* The asyncRoute wrapper catches the throw, and handleErrors() converts
|
|
11
|
+
* the semantic error into the right HTTP status + JSON response.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import express from 'express';
|
|
15
|
+
import { errorClass, databaseClass } from '@bloomneo/appkit';
|
|
16
|
+
|
|
17
|
+
const error = errorClass.get();
|
|
18
|
+
const database = await databaseClass.get();
|
|
19
|
+
const app = express();
|
|
20
|
+
|
|
21
|
+
// ── Routes use asyncRoute + semantic error throws ───────────────────
|
|
22
|
+
app.get('/api/users/:id', error.asyncRoute(async (req, res) => {
|
|
23
|
+
const id = Number(req.params.id);
|
|
24
|
+
if (!Number.isFinite(id)) throw error.badRequest('id must be a number');
|
|
25
|
+
|
|
26
|
+
const user = await database.user.findUnique({ where: { id } });
|
|
27
|
+
if (!user) throw error.notFound('User not found');
|
|
28
|
+
|
|
29
|
+
res.json({ user });
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
app.post('/api/admin', error.asyncRoute(async (req, res) => {
|
|
33
|
+
if (req.body.role !== 'admin') throw error.forbidden('Admin role required');
|
|
34
|
+
// ...
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// ── Available semantic error throwers ───────────────────────────────
|
|
38
|
+
//
|
|
39
|
+
// throw error.badRequest('message') → 400
|
|
40
|
+
// throw error.unauthorized('message') → 401
|
|
41
|
+
// throw error.forbidden('message') → 403
|
|
42
|
+
// throw error.notFound('message') → 404
|
|
43
|
+
// throw error.conflict('message') → 409
|
|
44
|
+
// throw error.tooMany('message') → 429 (rate limiting)
|
|
45
|
+
// throw error.serverError('message') → 500
|
|
46
|
+
// throw error.internal('message') → 500 (alias for serverError)
|
|
47
|
+
// throw error.createError(503, 'message') → any code
|
|
48
|
+
|
|
49
|
+
// ── MOUNT error.handleErrors() LAST in the middleware stack ─────────
|
|
50
|
+
app.use(error.handleErrors());
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CANONICAL PATTERN — pub/sub events with auto-scaling backend.
|
|
3
|
+
*
|
|
4
|
+
* Copy this file when you need events. Default is in-process memory (single
|
|
5
|
+
* Node process). Set REDIS_URL to distribute events across processes / servers
|
|
6
|
+
* — same code works in both modes.
|
|
7
|
+
*
|
|
8
|
+
* Use namespaces (eventClass.get('users')) to isolate event streams.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { eventClass, loggerClass } from '@bloomneo/appkit';
|
|
12
|
+
|
|
13
|
+
const events = eventClass.get('users'); // namespaced
|
|
14
|
+
const logger = loggerClass.get('events');
|
|
15
|
+
|
|
16
|
+
// ── Subscribe to a single event ─────────────────────────────────────
|
|
17
|
+
events.on('user.created', async (data) => {
|
|
18
|
+
logger.info('New user', { userId: data.userId });
|
|
19
|
+
// ...trigger welcome email, analytics, etc.
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ── Subscribe to a wildcard pattern ─────────────────────────────────
|
|
23
|
+
events.on('user.*', async (eventName, data) => {
|
|
24
|
+
logger.debug(`User event: ${eventName}`, data);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ── Emit an event from a route handler ──────────────────────────────
|
|
28
|
+
import { errorClass } from '@bloomneo/appkit';
|
|
29
|
+
const error = errorClass.get();
|
|
30
|
+
|
|
31
|
+
export const createUserRoute = error.asyncRoute(async (req, res) => {
|
|
32
|
+
// ...create the user in the database
|
|
33
|
+
const newUser = { id: 123, email: req.body.email };
|
|
34
|
+
|
|
35
|
+
await events.emit('user.created', {
|
|
36
|
+
userId: newUser.id,
|
|
37
|
+
email: newUser.email,
|
|
38
|
+
timestamp: new Date(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
res.json({ user: newUser });
|
|
42
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CANONICAL PATTERN — structured logging with component tagging.
|
|
3
|
+
*
|
|
4
|
+
* Copy this file when you need logging. The pattern: get a component-tagged
|
|
5
|
+
* logger at module scope, then call .info() / .warn() / .error() with a
|
|
6
|
+
* message + meta object. Logs are structured JSON in production, pretty
|
|
7
|
+
* console output in development.
|
|
8
|
+
*
|
|
9
|
+
* Set BLOOM_LOGGER_FILE_PATH to also write to a file.
|
|
10
|
+
* Set BLOOM_LOGGER_HTTP_URL to ship to a centralized log collector.
|
|
11
|
+
* Same code works in all three modes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { loggerClass } from '@bloomneo/appkit';
|
|
15
|
+
|
|
16
|
+
// Component-tagged logger — use one per file or per logical area.
|
|
17
|
+
const logger = loggerClass.get('users');
|
|
18
|
+
|
|
19
|
+
export function exampleLogging() {
|
|
20
|
+
logger.debug('Lookup started', { userId: 42 }); // dev only
|
|
21
|
+
logger.info('User created', { userId: 42, email: 'a@b' });
|
|
22
|
+
logger.warn('Slow query', { ms: 3200, table: 'users' });
|
|
23
|
+
logger.error('Database connection lost', { host: 'db1' });
|
|
24
|
+
logger.fatal('OOM, exiting', { rss: process.memoryUsage().rss });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Inside a route handler ──────────────────────────────────────────
|
|
28
|
+
import { errorClass } from '@bloomneo/appkit';
|
|
29
|
+
const error = errorClass.get();
|
|
30
|
+
|
|
31
|
+
export const createUserRoute = error.asyncRoute(async (req, res) => {
|
|
32
|
+
logger.info('Creating user', { ip: req.ip, email: req.body.email });
|
|
33
|
+
try {
|
|
34
|
+
// ...create logic
|
|
35
|
+
logger.info('User created successfully', { userId: 123 });
|
|
36
|
+
res.json({ ok: true });
|
|
37
|
+
} catch (e) {
|
|
38
|
+
logger.error('User creation failed', { error: (e as Error).message });
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CANONICAL PATTERN — background job queue with auto-scaling backend.
|
|
3
|
+
*
|
|
4
|
+
* Copy this file when you need background jobs. Default backend is in-process
|
|
5
|
+
* memory (good for dev). Set REDIS_URL to upgrade to distributed Redis.
|
|
6
|
+
*
|
|
7
|
+
* Same code works for all backends — no changes needed.
|
|
8
|
+
*
|
|
9
|
+
* queue.add(jobType, data, options) — enqueue immediately or with a fixed delay
|
|
10
|
+
* queue.schedule(jobType, data, delayMs) — alias: enqueue with a delay in ms
|
|
11
|
+
* queue.process(jobType, handler) — register a worker for that job type
|
|
12
|
+
*
|
|
13
|
+
* For cron-style recurring jobs: use a cron library (node-cron, etc.) to call
|
|
14
|
+
* queue.add() at the right interval — there is no built-in cron scheduler.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { queueClass, loggerClass, errorClass } from '@bloomneo/appkit';
|
|
18
|
+
|
|
19
|
+
const queue = queueClass.get();
|
|
20
|
+
const logger = loggerClass.get('queue');
|
|
21
|
+
const error = errorClass.get();
|
|
22
|
+
|
|
23
|
+
// ── Producer: enqueue jobs from a route ─────────────────────────────
|
|
24
|
+
export const enqueueEmailRoute = error.asyncRoute(async (req, res) => {
|
|
25
|
+
const { to, subject, body } = req.body;
|
|
26
|
+
|
|
27
|
+
await queue.add(
|
|
28
|
+
'send-email',
|
|
29
|
+
{ to, subject, body },
|
|
30
|
+
{
|
|
31
|
+
delay: 0, // run immediately (ms)
|
|
32
|
+
attempts: 3, // retry up to 3 times on failure (not "retries")
|
|
33
|
+
priority: 1, // lower number = higher priority
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
res.json({ queued: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ── Consumer: process jobs (run in a worker process or main app) ────
|
|
41
|
+
queue.process('send-email', async (data) => {
|
|
42
|
+
const { to, subject, body } = data as { to: string; subject: string; body: string };
|
|
43
|
+
logger.info('Email sent', { to, subject });
|
|
44
|
+
return { sent: true };
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── Delayed job (run once after N ms, not cron-style) ───────────────
|
|
48
|
+
// To run cleanup every day at 03:00, use node-cron to call queue.add() at the
|
|
49
|
+
// right time — queue.schedule() accepts a delay in milliseconds, not a cron expression.
|
|
50
|
+
await queue.schedule(
|
|
51
|
+
'cleanup-expired-tokens',
|
|
52
|
+
{},
|
|
53
|
+
8 * 60 * 60 * 1000, // run once, 8 hours from now
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
queue.process('cleanup-expired-tokens', async () => {
|
|
57
|
+
logger.info('Token cleanup ran');
|
|
58
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CANONICAL PATTERN — rate limiting, CSRF, encryption, input sanitization.
|
|
3
|
+
*
|
|
4
|
+
* Copy this file when you need security middleware. AppKit's security module
|
|
5
|
+
* covers four common needs from one xxxClass.get() instance.
|
|
6
|
+
*
|
|
7
|
+
* Required env: BLOOM_SECURITY_CSRF_SECRET, BLOOM_SECURITY_ENCRYPTION_KEY
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { securityClass, errorClass } from '@bloomneo/appkit';
|
|
11
|
+
|
|
12
|
+
const security = securityClass.get();
|
|
13
|
+
const error = errorClass.get();
|
|
14
|
+
|
|
15
|
+
// ── Rate limiting middleware ────────────────────────────────────────
|
|
16
|
+
// 100 requests per 15-minute window per IP
|
|
17
|
+
export const apiRateLimit = security.requests(100, 15 * 60 * 1000);
|
|
18
|
+
|
|
19
|
+
// 5 requests per minute (e.g. for a login endpoint)
|
|
20
|
+
export const loginRateLimit = security.requests(5, 60 * 1000);
|
|
21
|
+
|
|
22
|
+
// ── CSRF protection ─────────────────────────────────────────────────
|
|
23
|
+
// security.forms() is a single middleware that handles BOTH:
|
|
24
|
+
// - Injecting a CSRF token into the response (GET requests)
|
|
25
|
+
// - Validating the token on state-changing requests (POST/PUT/DELETE)
|
|
26
|
+
// Mount it once globally — no separate "require" middleware needed.
|
|
27
|
+
export const csrfMiddleware = security.forms();
|
|
28
|
+
|
|
29
|
+
// ── Encryption (AES-256-GCM) ────────────────────────────────────────
|
|
30
|
+
export function encryptApiKey(plaintext: string): string {
|
|
31
|
+
return security.encrypt(plaintext); // → opaque ciphertext string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function decryptApiKey(ciphertext: string): string {
|
|
35
|
+
return security.decrypt(ciphertext);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Input sanitization ──────────────────────────────────────────────
|
|
39
|
+
// security.input() strips XSS payloads and control characters from free-form text.
|
|
40
|
+
// For email/URL validation, use a dedicated validation library (e.g. zod, validator.js).
|
|
41
|
+
export const submitRoute = error.asyncRoute(async (req, res) => {
|
|
42
|
+
const safeMessage = security.input(req.body.message); // strip XSS
|
|
43
|
+
const safeHtml = security.html(req.body.bio); // allow safe HTML tags only
|
|
44
|
+
|
|
45
|
+
res.json({ ok: true, sanitized: { message: safeMessage, bio: safeHtml } });
|
|
46
|
+
});
|