@venizia/ignis-docs 0.0.8 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/content/best-practices/api-usage-examples.md +15 -12
- package/content/best-practices/architectural-patterns.md +70 -78
- package/content/best-practices/architecture-decisions.md +91 -60
- package/content/best-practices/code-style-standards/advanced-patterns.md +56 -44
- package/content/best-practices/code-style-standards/constants-configuration.md +11 -11
- package/content/best-practices/code-style-standards/control-flow.md +5 -2
- package/content/best-practices/code-style-standards/documentation.md +13 -13
- package/content/best-practices/code-style-standards/function-patterns.md +9 -10
- package/content/best-practices/code-style-standards/index.md +1 -1
- package/content/best-practices/code-style-standards/naming-conventions.md +10 -8
- package/content/best-practices/code-style-standards/route-definitions.md +30 -12
- package/content/best-practices/code-style-standards/tooling.md +8 -5
- package/content/best-practices/code-style-standards/type-safety.md +13 -12
- package/content/best-practices/common-pitfalls.md +56 -37
- package/content/best-practices/contribution-workflow.md +13 -14
- package/content/best-practices/data-modeling.md +44 -20
- package/content/best-practices/deployment-strategies.md +28 -27
- package/content/best-practices/error-handling.md +48 -24
- package/content/best-practices/index.md +5 -5
- package/content/best-practices/performance-optimization.md +36 -28
- package/content/best-practices/security-guidelines.md +52 -23
- package/content/best-practices/testing-strategies.md +65 -51
- package/content/best-practices/troubleshooting-tips.md +24 -24
- package/content/extensions/components/{swagger.md → api-reference.md} +40 -31
- package/content/extensions/components/authentication/api.md +19 -19
- package/content/extensions/components/authentication/errors.md +7 -7
- package/content/extensions/components/authentication/index.md +10 -8
- package/content/extensions/components/authentication/usage.md +101 -6
- package/content/extensions/components/authorization/api.md +45 -25
- package/content/extensions/components/authorization/errors.md +6 -6
- package/content/extensions/components/authorization/index.md +11 -10
- package/content/extensions/components/authorization/usage.md +21 -21
- package/content/extensions/components/health-check.md +1 -1
- package/content/extensions/components/index.md +5 -5
- package/content/extensions/components/mail/errors.md +15 -15
- package/content/extensions/components/mail/index.md +1 -2
- package/content/extensions/components/mail/usage.md +1 -1
- package/content/extensions/components/request-tracker.md +1 -1
- package/content/extensions/components/socket-io/api.md +9 -9
- package/content/extensions/components/socket-io/errors.md +5 -5
- package/content/extensions/components/socket-io/index.md +8 -8
- package/content/extensions/components/socket-io/usage.md +1 -1
- package/content/extensions/components/static-asset/api.md +17 -4
- package/content/extensions/components/static-asset/errors.md +4 -4
- package/content/extensions/components/static-asset/index.md +26 -28
- package/content/extensions/components/static-asset/usage.md +13 -12
- package/content/extensions/components/template/index.md +2 -2
- package/content/extensions/components/template/setup-page.md +1 -1
- package/content/extensions/components/websocket/api.md +3 -3
- package/content/extensions/components/websocket/errors.md +5 -5
- package/content/extensions/components/websocket/index.md +5 -5
- package/content/extensions/components/websocket/usage.md +3 -3
- package/content/extensions/helpers/cron/index.md +2 -2
- package/content/extensions/helpers/crypto/index.md +1 -1
- package/content/extensions/helpers/env/index.md +27 -12
- package/content/extensions/helpers/error/index.md +81 -25
- package/content/extensions/helpers/index.md +2 -3
- package/content/extensions/helpers/inversion/index.md +15 -7
- package/content/extensions/helpers/kafka/examples.md +1 -1
- package/content/extensions/helpers/logger/index.md +32 -2
- package/content/extensions/helpers/network/index.md +6 -0
- package/content/extensions/helpers/queue/index.md +14 -17
- package/content/extensions/helpers/redis/index.md +548 -323
- package/content/extensions/helpers/socket-io/index.md +14 -10
- package/content/extensions/helpers/storage/api.md +44 -8
- package/content/extensions/helpers/storage/index.md +43 -7
- package/content/extensions/helpers/template/index.md +6 -3
- package/content/extensions/helpers/types/index.md +11 -8
- package/content/extensions/helpers/websocket/api.md +9 -9
- package/content/extensions/helpers/websocket/index.md +7 -7
- package/content/extensions/helpers/worker-thread/index.md +2 -2
- package/content/extensions/index.md +3 -4
- package/content/extensions/src-details/mcp-server.md +18 -24
- package/content/guides/core-concepts/application/bootstrapping.md +11 -14
- package/content/guides/core-concepts/application/index.md +3 -3
- package/content/guides/core-concepts/components.md +19 -10
- package/content/guides/core-concepts/dependency-injection.md +6 -3
- package/content/guides/core-concepts/grpc-controllers.md +6 -5
- package/content/guides/core-concepts/persistent/datasources.md +33 -27
- package/content/guides/core-concepts/persistent/index.md +16 -5
- package/content/guides/core-concepts/persistent/models.md +24 -20
- package/content/guides/core-concepts/persistent/postgres-drivers.md +167 -0
- package/content/guides/core-concepts/persistent/repositories.md +40 -23
- package/content/guides/core-concepts/persistent/search-meilisearch.md +183 -0
- package/content/guides/core-concepts/persistent/search-typesense.md +429 -0
- package/content/guides/core-concepts/persistent/transactions.md +61 -25
- package/content/guides/core-concepts/rest-controllers.md +12 -9
- package/content/guides/core-concepts/services.md +330 -60
- package/content/guides/get-started/5-minute-quickstart.md +15 -15
- package/content/guides/get-started/philosophy.md +36 -36
- package/content/guides/get-started/setup.md +3 -3
- package/content/guides/index.md +3 -3
- package/content/guides/migrations/redis-helpers-migration.md +177 -0
- package/content/guides/migrations/scoped-rbac-migration.md +17 -17
- package/content/guides/migrations/unified-connectors-migration.md +113 -0
- package/content/guides/reference/glossary.md +19 -12
- package/content/guides/reference/mcp-docs-server.md +22 -18
- package/content/guides/tutorials/building-a-crud-api.md +30 -33
- package/content/guides/tutorials/complete-installation.md +17 -17
- package/content/guides/tutorials/ecommerce-api.md +158 -119
- package/content/guides/tutorials/realtime-chat.md +176 -130
- package/content/guides/tutorials/testing.md +65 -523
- package/content/index.md +2 -180
- package/content/public/apple-touch-icon.png +0 -0
- package/content/public/og-image.png +0 -0
- package/content/public/site.webmanifest +11 -0
- package/content/references/base/application.md +4 -5
- package/content/references/base/bootstrapping.md +18 -5
- package/content/references/base/components.md +149 -120
- package/content/references/base/connectors.md +178 -0
- package/content/references/base/controllers.md +41 -30
- package/content/references/base/datasources.md +154 -81
- package/content/references/base/dependency-injection.md +34 -22
- package/content/references/base/filter-system/application-usage.md +17 -14
- package/content/references/base/filter-system/array-operators.md +7 -2
- package/content/references/base/filter-system/comparison-operators.md +3 -0
- package/content/references/base/filter-system/default-filter.md +89 -71
- package/content/references/base/filter-system/fields-order-pagination.md +22 -22
- package/content/references/base/filter-system/index.md +6 -3
- package/content/references/base/filter-system/json-filtering.md +20 -1
- package/content/references/base/filter-system/list-operators.md +1 -1
- package/content/references/base/filter-system/logical-operators.md +33 -1
- package/content/references/base/filter-system/null-operators.md +30 -1
- package/content/references/base/filter-system/quick-reference.md +23 -4
- package/content/references/base/filter-system/tips.md +5 -5
- package/content/references/base/filter-system/use-cases.md +12 -12
- package/content/references/base/grpc-controllers.md +13 -13
- package/content/references/base/index.md +24 -12
- package/content/references/base/middlewares.md +265 -327
- package/content/references/base/models.md +63 -49
- package/content/references/base/providers.md +136 -130
- package/content/references/base/repositories/advanced.md +59 -58
- package/content/references/base/repositories/index.md +115 -91
- package/content/references/base/repositories/mixins.md +55 -291
- package/content/references/base/repositories/relations.md +54 -64
- package/content/references/base/repositories/soft-deletable.md +31 -30
- package/content/references/base/services.md +296 -93
- package/content/references/configuration/environment-variables.md +46 -30
- package/content/references/configuration/index.md +6 -6
- package/content/references/index.md +17 -12
- package/content/references/quick-reference.md +65 -106
- package/content/references/utilities/crypto.md +65 -23
- package/content/references/utilities/index.md +3 -3
- package/content/references/utilities/jsx.md +6 -4
- package/content/references/utilities/module.md +68 -20
- package/content/references/utilities/parse.md +4 -14
- package/content/references/utilities/promise.md +9 -7
- package/content/references/utilities/schema.md +5 -3
- package/dist/mcp-server/common/guards.d.ts +8 -0
- package/dist/mcp-server/common/guards.d.ts.map +1 -0
- package/dist/mcp-server/common/guards.js +14 -0
- package/dist/mcp-server/common/guards.js.map +1 -0
- package/dist/mcp-server/common/index.d.ts +1 -0
- package/dist/mcp-server/common/index.d.ts.map +1 -1
- package/dist/mcp-server/common/index.js +1 -0
- package/dist/mcp-server/common/index.js.map +1 -1
- package/dist/mcp-server/helpers/docs.helper.d.ts.map +1 -1
- package/dist/mcp-server/helpers/docs.helper.js +4 -2
- package/dist/mcp-server/helpers/docs.helper.js.map +1 -1
- package/dist/mcp-server/helpers/github.helper.js +1 -1
- package/dist/mcp-server/index.js +7 -2
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/mcp-server/tools/base.tool.d.ts +6 -2
- package/dist/mcp-server/tools/base.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/base.tool.js.map +1 -1
- package/dist/mcp-server/tools/docs/search-documents.tool.d.ts +1 -1
- package/dist/mcp-server/tools/github/list-project-files.tool.d.ts +1 -1
- package/dist/mcp-server/tools/github/search-code.tool.d.ts +1 -1
- package/dist/mcp-server/tools/github/search-code.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/github/search-code.tool.js +4 -1
- package/dist/mcp-server/tools/github/search-code.tool.js.map +1 -1
- package/dist/mcp-server/tools/github/verify-dependencies.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/github/verify-dependencies.tool.js +3 -1
- package/dist/mcp-server/tools/github/verify-dependencies.tool.js.map +1 -1
- package/package.json +9 -9
- package/content/extensions/helpers/testing/index.md +0 -510
- package/content/references/base/middleware.md +0 -347
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Performance Optimization
|
|
2
2
|
|
|
3
|
-
Optimize your
|
|
3
|
+
Optimize your IGNIS application for speed and scalability.
|
|
4
4
|
|
|
5
5
|
## 1. Measure Performance
|
|
6
6
|
|
|
@@ -46,7 +46,7 @@ Prevent blocking the event loop with Worker Threads:
|
|
|
46
46
|
|
|
47
47
|
### Query Operators Reference
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
IGNIS supports extensive query operators for filtering:
|
|
50
50
|
|
|
51
51
|
| Operator | Description | Example |
|
|
52
52
|
|----------|-------------|---------|
|
|
@@ -68,7 +68,7 @@ Ignis supports extensive query operators for filtering:
|
|
|
68
68
|
**Complex Filter Example:**
|
|
69
69
|
|
|
70
70
|
```typescript
|
|
71
|
-
await
|
|
71
|
+
await repository.find({
|
|
72
72
|
filter: {
|
|
73
73
|
where: {
|
|
74
74
|
and: [
|
|
@@ -87,22 +87,22 @@ await repo.find({
|
|
|
87
87
|
|
|
88
88
|
### JSON Path Filtering
|
|
89
89
|
|
|
90
|
-
Filter by nested JSON/JSONB fields using PostgreSQL
|
|
90
|
+
Filter and sort by nested JSON/JSONB fields using PostgreSQL path extraction:
|
|
91
91
|
|
|
92
92
|
```typescript
|
|
93
93
|
// Order by nested JSON path
|
|
94
|
-
await
|
|
94
|
+
await repository.find({
|
|
95
95
|
filter: {
|
|
96
96
|
order: ['metadata.nested[0].field ASC'],
|
|
97
97
|
},
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
100
|
+
// Ordering uses the #> operator: metadata #> '{nested,0,field}'
|
|
101
|
+
// Where clauses on JSON paths use #>> (text extraction)
|
|
102
102
|
```
|
|
103
103
|
|
|
104
104
|
> [!TIP]
|
|
105
|
-
> **Avoid Deep Nesting:** While
|
|
105
|
+
> **Avoid Deep Nesting:** While IGNIS supports deeply nested `include` filters, each level adds significant overhead to query construction and result mapping. We strongly recommend a **maximum of 2 levels** (e.g., `User -> Orders -> Items`). For more complex data fetching, consider separate queries.
|
|
106
106
|
|
|
107
107
|
**Example:**
|
|
108
108
|
```typescript
|
|
@@ -132,11 +132,15 @@ Reduce database load with caching:
|
|
|
132
132
|
|
|
133
133
|
**Example:**
|
|
134
134
|
```typescript
|
|
135
|
-
// Cache expensive query results
|
|
136
|
-
const cached = await redis.
|
|
135
|
+
// Cache expensive query results (RedisHelper uses an options-object API)
|
|
136
|
+
const cached = await redis.getObject<TUser[]>({ key: 'users:active' });
|
|
137
137
|
if (!cached) {
|
|
138
|
-
const users = await userRepository.find({ where: { active: true } });
|
|
139
|
-
await redis.set(
|
|
138
|
+
const users = await userRepository.find({ filter: { where: { active: true } } });
|
|
139
|
+
await redis.set({
|
|
140
|
+
key: 'users:active',
|
|
141
|
+
value: users,
|
|
142
|
+
options: { expiresIn: 5 * 60 * 1000 }, // 5 min TTL (milliseconds)
|
|
143
|
+
});
|
|
140
144
|
}
|
|
141
145
|
```
|
|
142
146
|
|
|
@@ -204,13 +208,16 @@ Connection pooling significantly improves performance by reusing database connec
|
|
|
204
208
|
```typescript
|
|
205
209
|
import { Pool } from 'pg';
|
|
206
210
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
211
|
+
import { BasePostgresDataSource } from '@venizia/ignis/postgres';
|
|
207
212
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
213
|
+
// IDataSourceConfigs: your settings interface (host/port/user/password/database)
|
|
214
|
+
export class PostgresDataSource extends BasePostgresDataSource<IDataSourceConfigs> {
|
|
215
|
+
override configure(): void {
|
|
216
|
+
// Keep the pool on `this.client` - beginTransaction() resolves its driver from it
|
|
217
|
+
this.client = new Pool({
|
|
211
218
|
host: this.settings.host,
|
|
212
219
|
port: this.settings.port,
|
|
213
|
-
user: this.settings.
|
|
220
|
+
user: this.settings.user,
|
|
214
221
|
password: this.settings.password,
|
|
215
222
|
database: this.settings.database,
|
|
216
223
|
|
|
@@ -222,7 +229,7 @@ export class PostgresDataSource extends AbstractDataSource {
|
|
|
222
229
|
maxUses: 7500, // Close connection after 7500 queries
|
|
223
230
|
});
|
|
224
231
|
|
|
225
|
-
this.connector = drizzle({ client:
|
|
232
|
+
this.connector = drizzle({ client: this.client, schema: this.getSchema() });
|
|
226
233
|
}
|
|
227
234
|
}
|
|
228
235
|
```
|
|
@@ -282,14 +289,14 @@ export const User = pgTable('User', {
|
|
|
282
289
|
### Avoid N+1 Queries
|
|
283
290
|
|
|
284
291
|
```typescript
|
|
285
|
-
// ❌ BAD - N+1 queries
|
|
286
|
-
const users = await
|
|
287
|
-
for (const user of users
|
|
288
|
-
user.posts = await
|
|
292
|
+
// ❌ BAD - N+1 queries (find returns a plain array)
|
|
293
|
+
const users = await userRepository.find({ filter: { limit: 100 } });
|
|
294
|
+
for (const user of users) {
|
|
295
|
+
user.posts = await postRepository.find({ filter: { where: { authorId: user.id } } });
|
|
289
296
|
}
|
|
290
297
|
|
|
291
298
|
// ✅ GOOD - Single query with relations
|
|
292
|
-
const users = await
|
|
299
|
+
const users = await userRepository.find({
|
|
293
300
|
filter: {
|
|
294
301
|
limit: 100,
|
|
295
302
|
include: [{ relation: 'posts' }],
|
|
@@ -302,11 +309,11 @@ const users = await userRepo.find({
|
|
|
302
309
|
```typescript
|
|
303
310
|
// ❌ BAD - Many individual inserts
|
|
304
311
|
for (const item of items) {
|
|
305
|
-
await
|
|
312
|
+
await repository.create({ data: item });
|
|
306
313
|
}
|
|
307
314
|
|
|
308
315
|
// ✅ GOOD - Batch insert
|
|
309
|
-
await
|
|
316
|
+
await repository.createAll({ data: items });
|
|
310
317
|
```
|
|
311
318
|
|
|
312
319
|
## 9. Memory Management
|
|
@@ -315,7 +322,7 @@ await repo.createMany({ data: items });
|
|
|
315
322
|
|
|
316
323
|
```typescript
|
|
317
324
|
// ❌ BAD - Load all records into memory
|
|
318
|
-
const allUsers = await
|
|
325
|
+
const allUsers = await userRepository.find({ filter: { limit: 100000 } });
|
|
319
326
|
|
|
320
327
|
// ✅ GOOD - Process in batches
|
|
321
328
|
const batchSize = 1000;
|
|
@@ -323,15 +330,16 @@ let offset = 0;
|
|
|
323
330
|
let hasMore = true;
|
|
324
331
|
|
|
325
332
|
while (hasMore) {
|
|
326
|
-
|
|
333
|
+
// find returns a plain array
|
|
334
|
+
const batch = await userRepository.find({
|
|
327
335
|
filter: { limit: batchSize, offset },
|
|
328
336
|
});
|
|
329
337
|
|
|
330
|
-
for (const user of batch
|
|
338
|
+
for (const user of batch) {
|
|
331
339
|
await processUser(user);
|
|
332
340
|
}
|
|
333
341
|
|
|
334
|
-
hasMore = batch.
|
|
342
|
+
hasMore = batch.length === batchSize;
|
|
335
343
|
offset += batchSize;
|
|
336
344
|
}
|
|
337
345
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Security Guidelines
|
|
2
2
|
|
|
3
|
-
Critical security practices to protect your
|
|
3
|
+
Critical security practices to protect your IGNIS application.
|
|
4
4
|
|
|
5
5
|
## 1. Secret Management
|
|
6
6
|
|
|
@@ -23,9 +23,27 @@ APP_ENV_POSTGRES_PASSWORD=database_password_here
|
|
|
23
23
|
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
### Redaction at Log Time
|
|
27
|
+
|
|
28
|
+
Never hard-coding a secret is not enough on its own -- an options object holding one still ends up in a log line the moment it is passed to `logger.info('...: %s', opts)`. `@venizia/ignis-helpers` provides two primitives for exactly this:
|
|
29
|
+
|
|
30
|
+
| Function | Use For | Behavior |
|
|
31
|
+
|----------|---------|----------|
|
|
32
|
+
| `redactSecrets(value)` | Any object/array being logged | Recursively replaces every value whose key matches a secret-looking name (`password`, `token`, `apiKey`, `authorization`, HTTP header spellings like `x-api-key`/`cookie`/`proxy-authorization`, etc., case-insensitive) with `'[REDACTED]'` |
|
|
33
|
+
| `redactUrlCredentials(url)` | A connection/broker URL string | Strips the password out of a URL's authority section (`mqtts://user:hunter2@broker:8883` becomes `mqtts://user:[REDACTED]@broker:8883`); a value that doesn't parse as a URL is returned unchanged |
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { redactSecrets, redactUrlCredentials } from '@venizia/ignis-helpers';
|
|
37
|
+
|
|
38
|
+
this.logger.info('[connect] Options: %s', redactSecrets(connectionOptions));
|
|
39
|
+
this.logger.info('[connect] Broker: %s', redactUrlCredentials(brokerUrl));
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
These are the primitives the framework itself uses -- outbound HTTP request configs (`NodeFetcher`/`AxiosFetcher`, see [Network Helper](/extensions/helpers/network/)) and MQTT broker URLs (`MQTTClientHelper`, see [Queue Helper](/extensions/helpers/queue/)) are both redacted this way before they reach a log line.
|
|
43
|
+
|
|
26
44
|
## 2. Input Validation
|
|
27
45
|
|
|
28
|
-
**Always validate incoming data** with Zod schemas.
|
|
46
|
+
**Always validate incoming data** with Zod schemas. IGNIS automatically rejects invalid requests.
|
|
29
47
|
|
|
30
48
|
```typescript
|
|
31
49
|
import { z } from '@hono/zod-openapi';
|
|
@@ -63,7 +81,7 @@ this.component(AuthenticateComponent);
|
|
|
63
81
|
```typescript
|
|
64
82
|
const SecureRoute = {
|
|
65
83
|
path: '/admin/users',
|
|
66
|
-
|
|
84
|
+
authenticate: { strategies: [Authentication.STRATEGY_JWT] }, // Requires JWT
|
|
67
85
|
// ...
|
|
68
86
|
};
|
|
69
87
|
```
|
|
@@ -73,10 +91,10 @@ const SecureRoute = {
|
|
|
73
91
|
**Access user in protected routes:**
|
|
74
92
|
```typescript
|
|
75
93
|
import { Authentication, IJWTTokenPayload } from '@venizia/ignis';
|
|
76
|
-
import {
|
|
94
|
+
import { getError } from '@venizia/ignis-helpers';
|
|
77
95
|
|
|
78
96
|
const user = c.get(Authentication.CURRENT_USER) as IJWTTokenPayload;
|
|
79
|
-
if (!user.roles.
|
|
97
|
+
if (!user.roles.some(role => role.identifier === 'admin')) {
|
|
80
98
|
throw getError({ statusCode: 403, message: 'Forbidden' });
|
|
81
99
|
}
|
|
82
100
|
```
|
|
@@ -92,7 +110,7 @@ Configure model properties that should **never be returned** through repository
|
|
|
92
110
|
hiddenProperties: ['password', 'apiSecret', 'internalToken'],
|
|
93
111
|
},
|
|
94
112
|
})
|
|
95
|
-
export class User extends
|
|
113
|
+
export class User extends BasePostgresEntity<typeof User.schema> {
|
|
96
114
|
static override schema = pgTable('User', {
|
|
97
115
|
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
98
116
|
email: text('email').notNull(),
|
|
@@ -113,7 +131,7 @@ export class User extends BaseEntity<typeof User.schema> {
|
|
|
113
131
|
|
|
114
132
|
```typescript
|
|
115
133
|
// For authentication - access password via connector
|
|
116
|
-
const connector =
|
|
134
|
+
const connector = userRepository.getConnector();
|
|
117
135
|
const [user] = await connector
|
|
118
136
|
.select({ id: User.schema.id, password: User.schema.password })
|
|
119
137
|
.from(User.schema)
|
|
@@ -227,14 +245,14 @@ Configure Cross-Origin Resource Sharing to control which domains can access your
|
|
|
227
245
|
import { cors } from 'hono/cors';
|
|
228
246
|
|
|
229
247
|
// Allow all origins (ONLY for development)
|
|
230
|
-
this.
|
|
248
|
+
this.getServer().use('*', cors());
|
|
231
249
|
```
|
|
232
250
|
|
|
233
251
|
**Production (Restrictive):**
|
|
234
252
|
```typescript
|
|
235
253
|
import { cors } from 'hono/cors';
|
|
236
254
|
|
|
237
|
-
this.
|
|
255
|
+
this.getServer().use('/api/*', cors({
|
|
238
256
|
origin: ['https://yourdomain.com', 'https://app.yourdomain.com'],
|
|
239
257
|
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
240
258
|
allowHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
|
|
@@ -246,7 +264,7 @@ this.server.use('/api/*', cors({
|
|
|
246
264
|
|
|
247
265
|
**Dynamic Origin Validation:**
|
|
248
266
|
```typescript
|
|
249
|
-
this.
|
|
267
|
+
this.getServer().use('/api/*', cors({
|
|
250
268
|
origin: (origin) => {
|
|
251
269
|
const allowedDomains = ['yourdomain.com', 'yourdomain.io'];
|
|
252
270
|
try {
|
|
@@ -296,9 +314,10 @@ const rateLimiter = (opts: { windowMs: number; max: number }) => {
|
|
|
296
314
|
};
|
|
297
315
|
|
|
298
316
|
// Apply to sensitive endpoints
|
|
299
|
-
this.
|
|
300
|
-
|
|
301
|
-
|
|
317
|
+
const server = this.getServer();
|
|
318
|
+
server.use('/api/auth/login', rateLimiter({ windowMs: 15 * 60 * 1000, max: 5 }));
|
|
319
|
+
server.use('/api/auth/register', rateLimiter({ windowMs: 60 * 60 * 1000, max: 10 }));
|
|
320
|
+
server.use('/api/*', rateLimiter({ windowMs: 60 * 1000, max: 100 }));
|
|
302
321
|
```
|
|
303
322
|
|
|
304
323
|
**Recommended Limits:**
|
|
@@ -313,16 +332,22 @@ this.server.use('/api/*', rateLimiter({ windowMs: 60 * 1000, max: 100 }));
|
|
|
313
332
|
**Production Recommendation:** Use Redis-backed rate limiting for distributed deployments:
|
|
314
333
|
|
|
315
334
|
```typescript
|
|
316
|
-
import {
|
|
335
|
+
import { RedisSingleHelper } from '@venizia/ignis-helpers';
|
|
317
336
|
|
|
318
337
|
// Rate limiter with Redis for multi-instance deployments
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
338
|
+
const redisHelper = new RedisSingleHelper({
|
|
339
|
+
name: 'rate-limiter',
|
|
340
|
+
host: process.env.APP_ENV_REDIS_HOST ?? 'localhost',
|
|
341
|
+
port: process.env.APP_ENV_REDIS_PORT ?? '6379',
|
|
342
|
+
password: process.env.APP_ENV_REDIS_PASSWORD,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const distributedRateLimiter = async (opts: { key: string; max: number; windowSeconds: number }) => {
|
|
346
|
+
const current = await redisHelper.incr({ key: opts.key });
|
|
322
347
|
if (current === 1) {
|
|
323
|
-
await
|
|
348
|
+
await redisHelper.expire({ key: opts.key, seconds: opts.windowSeconds });
|
|
324
349
|
}
|
|
325
|
-
return current <= max;
|
|
350
|
+
return current <= opts.max;
|
|
326
351
|
};
|
|
327
352
|
```
|
|
328
353
|
|
|
@@ -369,7 +394,7 @@ Add security headers to protect against common attacks:
|
|
|
369
394
|
import { secureHeaders } from 'hono/secure-headers';
|
|
370
395
|
|
|
371
396
|
// Add security headers to all responses
|
|
372
|
-
this.
|
|
397
|
+
this.getServer().use('*', secureHeaders({
|
|
373
398
|
// Prevent clickjacking
|
|
374
399
|
xFrameOptions: 'DENY',
|
|
375
400
|
// Prevent MIME type sniffing
|
|
@@ -396,13 +421,13 @@ Prevent denial of service through large payloads:
|
|
|
396
421
|
import { bodyLimit } from 'hono/body-limit';
|
|
397
422
|
|
|
398
423
|
// Limit request body size
|
|
399
|
-
this.
|
|
424
|
+
this.getServer().use('/api/*', bodyLimit({
|
|
400
425
|
maxSize: 1024 * 1024, // 1MB for general API
|
|
401
426
|
onError: (c) => c.json({ message: 'Request body too large' }, 413),
|
|
402
427
|
}));
|
|
403
428
|
|
|
404
429
|
// Allow larger uploads for file endpoints
|
|
405
|
-
this.
|
|
430
|
+
this.getServer().use('/api/upload/*', bodyLimit({
|
|
406
431
|
maxSize: 50 * 1024 * 1024, // 50MB for file uploads
|
|
407
432
|
}));
|
|
408
433
|
```
|
|
@@ -413,13 +438,14 @@ Log security-relevant events for monitoring and forensics:
|
|
|
413
438
|
|
|
414
439
|
```typescript
|
|
415
440
|
import { BaseService } from '@venizia/ignis';
|
|
441
|
+
import { getError } from '@venizia/ignis-helpers';
|
|
416
442
|
|
|
417
443
|
export class AuthService extends BaseService {
|
|
418
444
|
async login(email: string, password: string, context: Context) {
|
|
419
445
|
const ip = context.req.header('x-forwarded-for') ?? 'unknown';
|
|
420
446
|
const userAgent = context.req.header('user-agent') ?? 'unknown';
|
|
421
447
|
|
|
422
|
-
const user = await this.
|
|
448
|
+
const user = await this.userRepository.findByEmail(email);
|
|
423
449
|
|
|
424
450
|
if (!user || !await this.verifyPassword(password, user.password)) {
|
|
425
451
|
// Log failed attempt
|
|
@@ -444,6 +470,9 @@ export class AuthService extends BaseService {
|
|
|
444
470
|
- Suspicious activity (rate limit hits, invalid tokens)
|
|
445
471
|
- Admin actions
|
|
446
472
|
|
|
473
|
+
> [!NOTE]
|
|
474
|
+
> When logging a request or connection config that might carry credentials, wrap it in `redactSecrets()` (or `redactUrlCredentials()` for a URL) rather than logging it raw -- see [Redaction at Log Time](#redaction-at-log-time) above. This is what the framework's own outbound HTTP and MQTT logging already does automatically.
|
|
475
|
+
|
|
447
476
|
## Security Checklist
|
|
448
477
|
|
|
449
478
|
Before deploying to production, verify:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Testing Strategies
|
|
2
2
|
|
|
3
|
-
Comprehensive testing guide for
|
|
3
|
+
Comprehensive testing guide for IGNIS applications using Bun's built-in test runner.
|
|
4
4
|
|
|
5
5
|
## Testing Philosophy
|
|
6
6
|
|
|
@@ -46,13 +46,16 @@ afterAll(async () => {
|
|
|
46
46
|
|
|
47
47
|
**`test/helpers/test-database.ts`:**
|
|
48
48
|
```typescript
|
|
49
|
+
import { sql } from 'drizzle-orm';
|
|
49
50
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
50
51
|
import { Pool } from 'pg';
|
|
52
|
+
import { PostgresDataSource } from '@/datasources';
|
|
51
53
|
import * as schema from '@/models';
|
|
52
54
|
|
|
53
55
|
export class TestDatabase {
|
|
54
56
|
private static pool: Pool;
|
|
55
57
|
private static db: ReturnType<typeof drizzle>;
|
|
58
|
+
private static dataSource: PostgresDataSource;
|
|
56
59
|
|
|
57
60
|
static async initialize() {
|
|
58
61
|
this.pool = new Pool({
|
|
@@ -63,12 +66,20 @@ export class TestDatabase {
|
|
|
63
66
|
database: process.env.TEST_DB_NAME ?? 'ignis_test',
|
|
64
67
|
});
|
|
65
68
|
this.db = drizzle({ client: this.pool, schema });
|
|
69
|
+
|
|
70
|
+
// Application DataSource - repositories take a DataSource, not a raw Drizzle instance
|
|
71
|
+
this.dataSource = new PostgresDataSource();
|
|
72
|
+
await this.dataSource.configure();
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
static getDb() {
|
|
69
76
|
return this.db;
|
|
70
77
|
}
|
|
71
78
|
|
|
79
|
+
static getDataSource() {
|
|
80
|
+
return this.dataSource;
|
|
81
|
+
}
|
|
82
|
+
|
|
72
83
|
static async truncateAll() {
|
|
73
84
|
const tables = Object.keys(schema);
|
|
74
85
|
for (const table of tables) {
|
|
@@ -116,19 +127,20 @@ import type { IUserRepository } from '@/repositories';
|
|
|
116
127
|
|
|
117
128
|
describe('UserService', () => {
|
|
118
129
|
let service: UserService;
|
|
119
|
-
let
|
|
130
|
+
let mockRepository: IUserRepository;
|
|
120
131
|
|
|
121
132
|
beforeEach(() => {
|
|
122
133
|
// Create mock repository
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
134
|
+
// find/findOne/findById return the rows directly; create/updateById return { count, data }
|
|
135
|
+
mockRepository = {
|
|
136
|
+
findById: mock(() => Promise.resolve(null)),
|
|
137
|
+
findOne: mock(() => Promise.resolve(null)),
|
|
138
|
+
create: mock((opts) => Promise.resolve({ count: 1, data: { id: 'new-id', ...opts.data } })),
|
|
139
|
+
updateById: mock(() => Promise.resolve({ count: 0, data: null })),
|
|
128
140
|
} as unknown as IUserRepository;
|
|
129
141
|
|
|
130
142
|
// Inject mock
|
|
131
|
-
service = new UserService(
|
|
143
|
+
service = new UserService(mockRepository);
|
|
132
144
|
});
|
|
133
145
|
|
|
134
146
|
describe('createUser', () => {
|
|
@@ -142,13 +154,13 @@ describe('UserService', () => {
|
|
|
142
154
|
email: 'test@example.com',
|
|
143
155
|
name: 'Test User',
|
|
144
156
|
});
|
|
145
|
-
expect(
|
|
157
|
+
expect(mockRepository.create).toHaveBeenCalledTimes(1);
|
|
146
158
|
});
|
|
147
159
|
|
|
148
160
|
it('should throw error for duplicate email', async () => {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
161
|
+
mockRepository.findOne = mock(() =>
|
|
162
|
+
Promise.resolve({ id: 'existing', email: 'test@example.com' }),
|
|
163
|
+
);
|
|
152
164
|
|
|
153
165
|
await expect(
|
|
154
166
|
service.createUser({ email: 'test@example.com', name: 'Test' })
|
|
@@ -160,7 +172,7 @@ describe('UserService', () => {
|
|
|
160
172
|
|
|
161
173
|
await service.createUser(userData);
|
|
162
174
|
|
|
163
|
-
const createCall = (
|
|
175
|
+
const createCall = (mockRepository.create as ReturnType<typeof mock>).mock.calls[0][0];
|
|
164
176
|
expect(createCall.data.password).not.toBe('secret123');
|
|
165
177
|
expect(createCall.data.password).toMatch(/^\$2[aby]?\$/); // bcrypt hash
|
|
166
178
|
});
|
|
@@ -168,7 +180,7 @@ describe('UserService', () => {
|
|
|
168
180
|
|
|
169
181
|
describe('updateUser', () => {
|
|
170
182
|
it('should throw NotFound when user does not exist', async () => {
|
|
171
|
-
|
|
183
|
+
mockRepository.findById = mock(() => Promise.resolve(null));
|
|
172
184
|
|
|
173
185
|
await expect(
|
|
174
186
|
service.updateUser('nonexistent', { name: 'New Name' })
|
|
@@ -176,17 +188,17 @@ describe('UserService', () => {
|
|
|
176
188
|
});
|
|
177
189
|
|
|
178
190
|
it('should only update provided fields', async () => {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
data: { ...opts.data, id: opts.id },
|
|
191
|
+
mockRepository.findById = mock(() =>
|
|
192
|
+
Promise.resolve({ id: '1', email: 'old@test.com', name: 'Old Name' }),
|
|
193
|
+
);
|
|
194
|
+
mockRepository.updateById = mock((opts) => Promise.resolve({
|
|
184
195
|
count: 1,
|
|
196
|
+
data: { ...opts.data, id: opts.id },
|
|
185
197
|
}));
|
|
186
198
|
|
|
187
199
|
await service.updateUser('1', { name: 'New Name' });
|
|
188
200
|
|
|
189
|
-
const updateCall = (
|
|
201
|
+
const updateCall = (mockRepository.updateById as ReturnType<typeof mock>).mock.calls[0][0];
|
|
190
202
|
expect(updateCall.data).toEqual({ name: 'New Name' });
|
|
191
203
|
expect(updateCall.data.email).toBeUndefined();
|
|
192
204
|
});
|
|
@@ -206,11 +218,11 @@ import { TestDatabase } from '@test/helpers/test-database';
|
|
|
206
218
|
import { User } from '@/models';
|
|
207
219
|
|
|
208
220
|
describe('UserRepository', () => {
|
|
209
|
-
let
|
|
221
|
+
let repository: UserRepository;
|
|
210
222
|
|
|
211
223
|
beforeEach(async () => {
|
|
212
|
-
|
|
213
|
-
|
|
224
|
+
// Repositories take the application DataSource, not a raw Drizzle instance
|
|
225
|
+
repository = new UserRepository(TestDatabase.getDataSource());
|
|
214
226
|
});
|
|
215
227
|
|
|
216
228
|
afterEach(async () => {
|
|
@@ -219,7 +231,7 @@ describe('UserRepository', () => {
|
|
|
219
231
|
|
|
220
232
|
describe('create', () => {
|
|
221
233
|
it('should create a user and return with generated id', async () => {
|
|
222
|
-
const result = await
|
|
234
|
+
const result = await repository.create({
|
|
223
235
|
data: { email: 'test@example.com', name: 'Test User' },
|
|
224
236
|
});
|
|
225
237
|
|
|
@@ -232,12 +244,12 @@ describe('UserRepository', () => {
|
|
|
232
244
|
});
|
|
233
245
|
|
|
234
246
|
it('should enforce unique email constraint', async () => {
|
|
235
|
-
await
|
|
247
|
+
await repository.create({
|
|
236
248
|
data: { email: 'test@example.com', name: 'First' },
|
|
237
249
|
});
|
|
238
250
|
|
|
239
251
|
await expect(
|
|
240
|
-
|
|
252
|
+
repository.create({ data: { email: 'test@example.com', name: 'Second' } })
|
|
241
253
|
).rejects.toThrow(); // Unique constraint violation
|
|
242
254
|
});
|
|
243
255
|
});
|
|
@@ -245,7 +257,7 @@ describe('UserRepository', () => {
|
|
|
245
257
|
describe('find', () => {
|
|
246
258
|
beforeEach(async () => {
|
|
247
259
|
// Seed test data
|
|
248
|
-
await
|
|
260
|
+
await repository.createAll({
|
|
249
261
|
data: [
|
|
250
262
|
{ email: 'alice@test.com', name: 'Alice', status: 'ACTIVE' },
|
|
251
263
|
{ email: 'bob@test.com', name: 'Bob', status: 'ACTIVE' },
|
|
@@ -255,31 +267,32 @@ describe('UserRepository', () => {
|
|
|
255
267
|
});
|
|
256
268
|
|
|
257
269
|
it('should filter by status', async () => {
|
|
258
|
-
|
|
270
|
+
// find() returns the rows directly (an array)
|
|
271
|
+
const users = await repository.find({
|
|
259
272
|
filter: { where: { status: 'ACTIVE' } },
|
|
260
273
|
});
|
|
261
274
|
|
|
262
|
-
expect(
|
|
263
|
-
expect(
|
|
264
|
-
expect(
|
|
275
|
+
expect(users).toHaveLength(2);
|
|
276
|
+
expect(users.map(user => user.name)).toContain('Alice');
|
|
277
|
+
expect(users.map(user => user.name)).toContain('Bob');
|
|
265
278
|
});
|
|
266
279
|
|
|
267
280
|
it('should support pagination', async () => {
|
|
268
|
-
const page1 = await
|
|
281
|
+
const page1 = await repository.find({
|
|
269
282
|
filter: { limit: 2, offset: 0, order: ['name ASC'] },
|
|
270
283
|
});
|
|
271
|
-
const page2 = await
|
|
284
|
+
const page2 = await repository.find({
|
|
272
285
|
filter: { limit: 2, offset: 2, order: ['name ASC'] },
|
|
273
286
|
});
|
|
274
287
|
|
|
275
|
-
expect(page1
|
|
276
|
-
expect(page1
|
|
277
|
-
expect(page2
|
|
278
|
-
expect(page2
|
|
288
|
+
expect(page1).toHaveLength(2);
|
|
289
|
+
expect(page1[0].name).toBe('Alice');
|
|
290
|
+
expect(page2).toHaveLength(1);
|
|
291
|
+
expect(page2[0].name).toBe('Charlie');
|
|
279
292
|
});
|
|
280
293
|
|
|
281
294
|
it('should support complex filters', async () => {
|
|
282
|
-
const
|
|
295
|
+
const users = await repository.find({
|
|
283
296
|
filter: {
|
|
284
297
|
where: {
|
|
285
298
|
or: [
|
|
@@ -290,34 +303,35 @@ describe('UserRepository', () => {
|
|
|
290
303
|
},
|
|
291
304
|
});
|
|
292
305
|
|
|
293
|
-
expect(
|
|
294
|
-
expect(
|
|
295
|
-
expect(
|
|
306
|
+
expect(users).toHaveLength(2);
|
|
307
|
+
expect(users.map(user => user.name)).toContain('Alice');
|
|
308
|
+
expect(users.map(user => user.name)).toContain('Charlie');
|
|
296
309
|
});
|
|
297
310
|
});
|
|
298
311
|
|
|
299
312
|
describe('relations', () => {
|
|
300
313
|
it('should load related entities', async () => {
|
|
301
314
|
// Assuming User has Posts relation
|
|
302
|
-
const
|
|
315
|
+
const created = await repository.create({
|
|
303
316
|
data: { email: 'author@test.com', name: 'Author' },
|
|
304
317
|
});
|
|
305
318
|
|
|
306
319
|
// Create posts for the user
|
|
307
|
-
const
|
|
308
|
-
await
|
|
320
|
+
const postRepository = new PostRepository(TestDatabase.getDataSource());
|
|
321
|
+
await postRepository.createAll({
|
|
309
322
|
data: [
|
|
310
|
-
{ title: 'Post 1', authorId:
|
|
311
|
-
{ title: 'Post 2', authorId:
|
|
323
|
+
{ title: 'Post 1', authorId: created.data!.id },
|
|
324
|
+
{ title: 'Post 2', authorId: created.data!.id },
|
|
312
325
|
],
|
|
313
326
|
});
|
|
314
327
|
|
|
315
|
-
|
|
316
|
-
|
|
328
|
+
// findById() returns the entity directly (or null)
|
|
329
|
+
const author = await repository.findById({
|
|
330
|
+
id: created.data!.id,
|
|
317
331
|
filter: { include: [{ relation: 'posts' }] },
|
|
318
332
|
});
|
|
319
333
|
|
|
320
|
-
expect(
|
|
334
|
+
expect(author?.posts).toHaveLength(2);
|
|
321
335
|
});
|
|
322
336
|
});
|
|
323
337
|
});
|
|
@@ -341,8 +355,8 @@ describe('UserController E2E', () => {
|
|
|
341
355
|
beforeAll(async () => {
|
|
342
356
|
await TestDatabase.initialize();
|
|
343
357
|
app = new Application();
|
|
344
|
-
await app.
|
|
345
|
-
client = testClient(app.
|
|
358
|
+
await app.initialize();
|
|
359
|
+
client = testClient(app.getServer());
|
|
346
360
|
});
|
|
347
361
|
|
|
348
362
|
afterEach(async () => {
|