Skip to content

Commit e9236e0

Browse files
committed
enable encryption for model columns
1 parent 7bf76e5 commit e9236e0

4 files changed

Lines changed: 133 additions & 47 deletions

File tree

README.md

Lines changed: 38 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ _A Modern, Type-Safe, and Expressive ORM for Bun_
3434
- **Connection Pooling**: Efficient connection management for PostgreSQL and MySQL.
3535
- **Transactional Integrity**: Built-in support for atomic transactions with automatic rollback on failure.
3636
- **Advanced Query Builder**: Fluent, chainable API for building complex queries, including joins, filters, ordering, and pagination.
37+
- **Pagination Helper**: Easily paginate any query with `.paginate(page, pageSize)` and get `{ data, total, page, pageSize }`.
38+
- **Advanced Model Validation**: Enforce rules like `required`, `minLength`, `maxLength`, `pattern`, and custom validators—errors are thrown on invalid input.
3739
- **Model Relationships**: Define `OneToOne`, `ManyToOne`, `OneToMany`, and `ManyToMany` relationships in the model configuration.
3840
- **Soft Deletes**: Enable soft deletes in the model configuration for transparent "deleted" flags and safe row removal.
3941
- **Lifecycle Hooks**: Define hooks in the model configuration or as class methods for lifecycle events like `beforeCreate`, `afterUpdate`, etc.
@@ -145,59 +147,55 @@ const User = defineModel({
145147
},
146148
});
147149

148-
// Add a hook as a class method
149-
User.prototype.afterCreate = async function () {
150-
console.log(`Created user with ID: ${this.id}`);
151-
};
152-
153150
export { User };
154151
```
155152

156-
```typescript
157-
// models/Role.ts
158-
import { defineModel, DataTypes } from "stabilize-orm";
153+
---
159154

160-
const Role = defineModel({
161-
tableName: "roles",
162-
columns: {
163-
id: { type: DataTypes.Integer, required: true },
164-
name: { type: DataTypes.String, length: 50, required: true, unique: true },
165-
},
166-
});
155+
## 🔍 Pagination
156+
157+
The built-in pagination helper makes it easy to retrieve paged results and total counts in a single call.
167158

168-
export { Role };
159+
```typescript
160+
const page = await userRepository.paginate(2, 10);
161+
// page = { data: [...], total: N, page: 2, pageSize: 10 }
169162
```
170163

164+
Or, use the query builder:
165+
171166
```typescript
172-
// models/UserRole.ts
173-
import { defineModel, DataTypes, RelationType } from "stabilize-orm";
174-
import { User } from "./User";
175-
import { Role } from "./Role";
167+
const page = await userRepository.find().where('isActive = ?', true).paginate(1, 20).execute();
168+
```
169+
170+
---
176171

177-
const UserRole = defineModel({
178-
tableName: "user_roles",
172+
## 🛡️ Advanced Validation
173+
174+
Models can define advanced validation rules for columns, including:
175+
176+
- `required`
177+
- `minLength` / `maxLength`
178+
- `pattern` (RegExp)
179+
- `customValidator` (function)
180+
181+
Validation errors are thrown on create/update if data is invalid.
182+
183+
```typescript
184+
const User = defineModel({
185+
tableName: "users",
179186
columns: {
180187
id: { type: DataTypes.Integer, required: true },
181-
userId: { type: DataTypes.Integer, required: true, index: "idx_user_id" },
182-
roleId: { type: DataTypes.Integer, required: true, index: "idx_role_id" },
183-
},
184-
relations: [
185-
{
186-
type: RelationType.ManyToOne,
187-
target: () => User,
188-
property: "user",
189-
foreignKey: "userId",
190-
},
191-
{
192-
type: RelationType.ManyToOne,
193-
target: () => Role,
194-
property: "role",
195-
foreignKey: "roleId",
188+
email: {
189+
type: DataTypes.String,
190+
required: true,
191+
unique: true,
192+
minLength: 6,
193+
pattern: /^[^@]+@[^@]+\.[^@]+$/,
194+
customValidator: (val) => val.endsWith("@offbytesecure.com") || "Must use an @offbytesecure.com email"
196195
},
197-
],
196+
password: { type: DataTypes.String, minLength: 8 },
197+
},
198198
});
199-
200-
export { UserRole };
201199
```
202200

203201
---

model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface ColumnConfig {
2323
maxLength?: number;
2424
pattern?: RegExp;
2525
customValidator?: (val: any) => boolean | string;
26+
encrypted?: boolean;
2627

2728
}
2829

repository.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
} from "./types";
1818
import { MetadataStorage } from "./model";
1919
import { getHooks, type HookType } from "./hooks";
20+
import { decrypt, encrypt } from "./utils/encryption";
2021

2122
type VersionOperation = "insert" | "update" | "delete";
2223

@@ -132,18 +133,18 @@ export class Repository<T> {
132133
for (const [key, rules] of Object.entries(this.validators)) {
133134
const value = (entity as any)[key];
134135

135-
// 🔹 Required validation
136+
// Required validation
136137
if (rules.includes("required") && (value === undefined || value === null)) {
137138
throw new StabilizeError(`Field ${key} is required`, "VALIDATION_ERROR");
138139
}
139140

140-
// 🔹 Skip further checks if field is empty and not required
141+
// Skip further checks if field is empty and not required
141142
if (value === undefined || value === null) continue;
142143

143144
const column = this.columns?.[key];
144145
if (!column) continue;
145146

146-
// 🔹 Length validation
147+
// Length validation
147148
if (column.minLength && typeof value === "string" && value.length < column.minLength) {
148149
throw new StabilizeError(`Field ${key} too short`, "VALIDATION_ERROR");
149150
}
@@ -152,12 +153,12 @@ export class Repository<T> {
152153
throw new StabilizeError(`Field ${key} too long`, "VALIDATION_ERROR");
153154
}
154155

155-
// 🔹 Pattern validation
156+
// Pattern validation
156157
if (column.pattern && typeof value === "string" && !column.pattern.test(value)) {
157158
throw new StabilizeError(`Field ${key} does not match pattern`, "VALIDATION_ERROR");
158159
}
159160

160-
// 🔹 Custom validator
161+
// Custom validator
161162
if (typeof column.customValidator === "function") {
162163
const result = column.customValidator(value);
163164
if (result !== true) {
@@ -238,10 +239,11 @@ export class Repository<T> {
238239
}
239240
const cacheKey = `findOne:${this.table}:${id}:${options.relations?.join(",")}`;
240241
const results = await queryBuilder.execute(client, this.cache!, cacheKey);
242+
const result = this.processForLoad(results);
241243
this.logger.logDebug(
242244
`Found ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
243245
);
244-
return results[0] || null;
246+
return result[0] || null;
245247
}
246248

247249
/**
@@ -423,9 +425,10 @@ export class Repository<T> {
423425
`Creating ${this.table} with data: ${JSON.stringify(entity)}`,
424426
);
425427
this.validate(entity);
428+
const entityToSave = this.processForSave(entity);
426429

427430
const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
428-
const entityWithTimestamps = { ...entity } as Record<string, any>;
431+
const entityWithTimestamps = { ...entityToSave } as Record<string, any>;
429432
if (timestamps.createdAt && !entityWithTimestamps[timestamps.createdAt]) {
430433
entityWithTimestamps[timestamps.createdAt] = new Date();
431434
}
@@ -443,6 +446,7 @@ export class Repository<T> {
443446
let id: number | string | undefined;
444447
const dbType = this.getDBType(client);
445448

449+
446450
if (dbType === DBType.Postgres) {
447451
query += " RETURNING *";
448452
insertedResult = await client.query<T>(query, params);
@@ -1174,4 +1178,31 @@ export class Repository<T> {
11741178
}
11751179

11761180

1181+
// Encrypt fields before save
1182+
private processForSave(entity: any): any {
1183+
const processed = { ...entity };
1184+
for (const [key, col] of Object.entries(this.columns)) {
1185+
if ((col as any).encrypted && processed[key]) {
1186+
processed[key] = encrypt(processed[key]);
1187+
}
1188+
}
1189+
return processed;
1190+
}
1191+
1192+
// Decrypt fields after load
1193+
private processForLoad(row: any): any {
1194+
const processed = { ...row };
1195+
for (const [key, col] of Object.entries(this.columns)) {
1196+
if ((col as any).encrypted && processed[key]) {
1197+
try {
1198+
processed[key] = decrypt(processed[key]);
1199+
} catch {
1200+
// Optionally, log or throw for corrupted/corrupt data
1201+
processed[key] = null;
1202+
}
1203+
}
1204+
}
1205+
return processed;
1206+
}
1207+
11771208
}

utils/encryption.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import crypto from "crypto";
2+
3+
const ENCRYPTION_KEY =
4+
process.env.ORM_ENCRYPTION_KEY || "f71a3c8e9b12d5a49c0a3f98b1f2e46d"; // 32 bytes for AES-256
5+
const IV_LENGTH = 16; // AES block size in bytes
6+
7+
/**
8+
* Encrypts a UTF-8 string using AES-256-CBC.
9+
* @param text - The plain text to encrypt.
10+
* @returns {string} Base64-encoded string in the format "iv:encrypted".
11+
*/
12+
export function encrypt(text: string): string {
13+
// Convert the encryption key properly (handle hex vs utf8)
14+
const keyBuffer = Buffer.from(ENCRYPTION_KEY, "utf8");
15+
if (keyBuffer.length !== 32) {
16+
throw new Error("ENCRYPTION_KEY must be 32 bytes (256 bits) long for AES-256");
17+
}
18+
19+
const iv = crypto.randomBytes(IV_LENGTH);
20+
const cipher = crypto.createCipheriv("aes-256-cbc", keyBuffer, iv);
21+
22+
const encrypted = Buffer.concat([
23+
cipher.update(text, "utf8"),
24+
cipher.final(),
25+
]);
26+
27+
return `${iv.toString("base64")}:${encrypted.toString("base64")}`;
28+
}
29+
30+
/**
31+
* Decrypts a string encrypted with `encrypt()`.
32+
* @param text - The Base64-encoded "iv:encrypted" string.
33+
* @returns {string} The decrypted plain text.
34+
*/
35+
export function decrypt(text: string): string {
36+
const [ivPart, encryptedPart] = text.split(":");
37+
if (!ivPart || !encryptedPart) {
38+
throw new Error("Invalid encrypted text format. Expected 'iv:encrypted'.");
39+
}
40+
41+
const iv = Buffer.from(ivPart, "base64");
42+
const encrypted = Buffer.from(encryptedPart, "base64");
43+
44+
const keyBuffer = Buffer.from(ENCRYPTION_KEY, "utf8");
45+
if (keyBuffer.length !== 32) {
46+
throw new Error("ENCRYPTION_KEY must be 32 bytes (256 bits) long for AES-256");
47+
}
48+
49+
const decipher = crypto.createDecipheriv("aes-256-cbc", keyBuffer, iv);
50+
const decrypted = Buffer.concat([
51+
decipher.update(encrypted),
52+
decipher.final(),
53+
]);
54+
55+
return decrypted.toString("utf8");
56+
}

0 commit comments

Comments
 (0)