@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.
- 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 +29 -29
- 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 +11 -11
- package/dist/config/index.d.ts +3 -3
- package/dist/config/index.js +4 -4
- package/dist/database/adapters/mongoose.d.ts +4 -4
- package/dist/database/adapters/mongoose.js +7 -7
- package/dist/database/adapters/prisma.d.ts +4 -4
- package/dist/database/adapters/prisma.js +7 -7
- 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 +26 -26
- package/dist/email/index.js +7 -7
- package/dist/email/strategies/resend.js +1 -1
- package/dist/error/defaults.d.ts +1 -1
- package/dist/error/defaults.js +13 -13
- 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 +35 -35
- package/dist/event/index.js +7 -7
- 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 +2 -2
- package/dist/logger/transports/http.d.ts +1 -1
- package/dist/logger/transports/http.js +2 -2
- package/dist/logger/transports/webhook.d.ts +1 -1
- package/dist/logger/transports/webhook.js +3 -3
- 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 +30 -30
- 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 +26 -26
- package/dist/storage/index.js +3 -3
- package/dist/util/defaults.d.ts +1 -1
- package/dist/util/defaults.js +41 -41
- 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/dist/util/util.js +1 -1
- 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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/util/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,SAAS,GAAG,wDAAwD,CAAC;AAE3E,MAAM,OAAO,WAAY,SAAQ,KAAK;IAC3B,MAAM,CAAS;IACf,OAAO,CAAS;IAEzB,YAAY,MAAc,EAAE,OAAe,EAAE,IAAa;QACxD,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,SAAS,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;QACtD,KAAK,CAAC,sBAAsB,MAAM,KAAK,OAAO,UAAU,GAAG,EAAE,CAAC,CAAC;QAC/D,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC;QAC1B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,OAAO,GAAG,GAAG,CAAC;IACrB,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,MAAc,EAAE,OAAe,EAAE,IAAa;IACvE,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IACnC,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;QACxC,MAAM,GAAG,GAAG,IAAI;YACd,CAAC,CAAC,sBAAsB,OAAO,iBAAiB,IAAI,EAAE;YACtD,CAAC,CAAC,sBAAsB,OAAO,eAAe,CAAC;QACjD,MAAM,IAAI,WAAW,CAAC,MAAM,EAAE,GAAG,EAAE,uBAAuB,CAAC,CAAC;IAC9D,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,WAAW,CACzB,MAAc,EACd,IAAY,EACZ,KAA2B,EAC3B,IAAa;IAEb,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;QAC1C,MAAM,GAAG,GAAG,IAAI;YACd,CAAC,CAAC,KAAK,IAAI,mBAAmB,IAAI,EAAE;YACpC,CAAC,CAAC,KAAK,IAAI,iBAAiB,CAAC;QAC/B,MAAM,IAAI,WAAW,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACrC,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,SAAS,CAAC,MAAc,EAAE,OAAe,EAAE,IAAa;IACtE,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY;QAAE,OAAO;IAClD,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAC,GAAG,SAAS,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IACtD,sCAAsC;IACtC,OAAO,CAAC,IAAI,CAAC,sBAAsB,MAAM,KAAK,OAAO,UAAU,GAAG,EAAE,CAAC,CAAC;AACxE,CAAC"}
|
package/dist/util/util.js
CHANGED
|
@@ -152,7 +152,7 @@ export class UtilClass {
|
|
|
152
152
|
}
|
|
153
153
|
// Performance warning for large arrays
|
|
154
154
|
if (this.config.performance.enabled && array.length > this.config.performance.chunkSizeLimit) {
|
|
155
|
-
console.warn(`[
|
|
155
|
+
console.warn(`[Bloomneo Utils] Chunking large array (${array.length} items). Consider streaming or pagination.`);
|
|
156
156
|
}
|
|
157
157
|
const result = [];
|
|
158
158
|
for (let i = 0; i < array.length; i += size) {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Bloomneo AppKit — canonical environment template
|
|
2
|
+
#
|
|
3
|
+
# Copy this to `.env` in your project root and fill in real values.
|
|
4
|
+
# All env vars use the BLOOM_ prefix. The legacy VOILA_ prefix from
|
|
5
|
+
# @voilajsx/appkit was removed in 1.5.2 — there is no fallback.
|
|
6
|
+
#
|
|
7
|
+
# Generate secrets with: openssl rand -base64 32
|
|
8
|
+
|
|
9
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
10
|
+
# REQUIRED for production
|
|
11
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
# JWT signing key for authClass.get().signToken() / verifyToken()
|
|
14
|
+
# Minimum 32 characters.
|
|
15
|
+
BLOOM_AUTH_SECRET=replace-me-with-openssl-rand-base64-32
|
|
16
|
+
|
|
17
|
+
# Prisma connection string. Any Prisma-supported URL works.
|
|
18
|
+
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp
|
|
19
|
+
|
|
20
|
+
# CSRF token signing for securityClass.get().csrf() / requireCsrf()
|
|
21
|
+
# Minimum 32 characters.
|
|
22
|
+
BLOOM_SECURITY_CSRF_SECRET=replace-me-with-openssl-rand-base64-32
|
|
23
|
+
|
|
24
|
+
# AES-256-GCM encryption key for securityClass.get().encrypt() / decrypt()
|
|
25
|
+
# Exactly 64 hex characters (32 bytes).
|
|
26
|
+
BLOOM_SECURITY_ENCRYPTION_KEY=replace-me-with-openssl-rand-hex-32
|
|
27
|
+
|
|
28
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
29
|
+
# OPTIONAL — auto-scaling kicks in when set
|
|
30
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
# Distributed cache + queue + events (memory by default)
|
|
33
|
+
# REDIS_URL=redis://localhost:6379
|
|
34
|
+
|
|
35
|
+
# Cloud storage (local disk by default)
|
|
36
|
+
# AWS_S3_BUCKET=my-bucket
|
|
37
|
+
# AWS_REGION=us-east-1
|
|
38
|
+
# AWS_ACCESS_KEY_ID=...
|
|
39
|
+
# AWS_SECRET_ACCESS_KEY=...
|
|
40
|
+
|
|
41
|
+
# Cloudflare R2 (alternative to S3)
|
|
42
|
+
# R2_BUCKET=my-bucket
|
|
43
|
+
# R2_ACCOUNT_ID=...
|
|
44
|
+
# R2_ACCESS_KEY_ID=...
|
|
45
|
+
# R2_SECRET_ACCESS_KEY=...
|
|
46
|
+
|
|
47
|
+
# Production email (console transport by default)
|
|
48
|
+
# RESEND_API_KEY=re_...
|
|
49
|
+
# or
|
|
50
|
+
# SMTP_HOST=smtp.example.com
|
|
51
|
+
# SMTP_PORT=587
|
|
52
|
+
# SMTP_USER=...
|
|
53
|
+
# SMTP_PASS=...
|
|
54
|
+
|
|
55
|
+
# Multi-tenant database filtering
|
|
56
|
+
# When set to "auto", database queries auto-filter by tenant_id.
|
|
57
|
+
# BLOOM_DB_TENANT=auto
|
|
58
|
+
|
|
59
|
+
# Per-organization databases (multi-org SaaS)
|
|
60
|
+
# Each ORG_<NAME> is a separate connection string used by databaseClass.org('<name>').get()
|
|
61
|
+
# ORG_ACME=postgresql://acme.aws.com/prod
|
|
62
|
+
# ORG_GLOBEX=postgresql://globex.aws.com/prod
|
|
63
|
+
|
|
64
|
+
# Background queue persistence (memory by default)
|
|
65
|
+
# BLOOM_QUEUE_DB=true # use database-backed queue when REDIS_URL not set
|
|
66
|
+
|
|
67
|
+
# Logger transports
|
|
68
|
+
# BLOOM_LOGGER_FILE_PATH=/var/log/myapp.log
|
|
69
|
+
# BLOOM_LOGGER_HTTP_URL=https://logs.example.com/ingest
|
|
70
|
+
# BLOOM_LOGGER_WEBHOOK_URL=https://hooks.slack.com/services/...
|
|
71
|
+
|
|
72
|
+
# Service identification (used by logger metadata)
|
|
73
|
+
# BLOOM_SERVICE_NAME=myapp
|
|
74
|
+
# BLOOM_SERVICE_VERSION=1.0.0
|
|
75
|
+
|
|
76
|
+
# Default user password for seeds (development only)
|
|
77
|
+
# DEFAULT_USER_PASSWORD=changeme123
|
|
78
|
+
|
|
79
|
+
# Frontend API key (for browser → API auth — generated by `appkit generate app`)
|
|
80
|
+
# BLOOM_FRONTEND_KEY=bloom_<random>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
One minimal `.ts` file per module. Each example is the smallest runnable shape
|
|
4
|
+
of the canonical pattern — copy, modify the data, ship.
|
|
5
|
+
|
|
6
|
+
For composed multi-module patterns (auth-protected CRUD, multi-tenant API,
|
|
7
|
+
file upload + queue, real-time chat), see [`../cookbook/`](../cookbook/).
|
|
8
|
+
|
|
9
|
+
## Conventions
|
|
10
|
+
|
|
11
|
+
- Always import from `@bloomneo/appkit` (or the subpath form).
|
|
12
|
+
- Always use `xxxClass.get()` to obtain a module instance.
|
|
13
|
+
- Always cache the module instance at module scope, not inside route handlers.
|
|
14
|
+
- Use semantic error throwers (`error.badRequest()`, `error.unauthorized()`),
|
|
15
|
+
never `throw new Error(...)` in route handlers.
|
|
16
|
+
- See [`.env.example`](./.env.example) for the canonical environment template.
|
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
|
+
});
|