@zerotal/orm 1.3.0 → 1.4.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/CHANGELOG.md +48 -0
- package/package.json +3 -3
- package/src/casts/encrypted.ts +168 -0
- package/src/index.ts +5 -0
- package/src/model/BaseModel.ts +66 -8
- package/src/model/ModelQueryBuilder.ts +18 -0
- package/src/model/decorators/column.ts +17 -1
- package/src/schema/ModelInspector.ts +30 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,54 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.4.0] — 2026-08-10
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Encrypted columns.** A column can now hold ciphertext at rest and plaintext on
|
|
16
|
+
the model, keyed by `APP_KEY` with AES-256-GCM. Declare it per-column or as a
|
|
17
|
+
list; the two mean the same thing and resolve to the same cast:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
@column("encrypted", { nullable: true }) idNumber?: string;
|
|
21
|
+
@column("encrypted:json") medical?: MedicalInfo;
|
|
22
|
+
|
|
23
|
+
// …the same, spelled out — `encrypted` is a cast, not a storage type:
|
|
24
|
+
@column({ type: "text", nullable: true, cast: "encrypted" }) passportNumber?: string;
|
|
25
|
+
|
|
26
|
+
static encryptable = ["idNumber", "passportNumber"];
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`encrypted` and `encrypted:json` join the `@column("…")` shorthands, resolving
|
|
30
|
+
to `{ type: "text", cast: "encrypted" }` — so the storage type is right without
|
|
31
|
+
having to know that ciphertext outgrows its plaintext.
|
|
32
|
+
|
|
33
|
+
Encryption happens on the way to the database rather than to the instance, so —
|
|
34
|
+
unlike `hashable` — it is non-destructive: after `save()` the property still
|
|
35
|
+
holds what you assigned. `$dirty` compares plaintext, so an unchanged column is
|
|
36
|
+
not rewritten with a fresh IV on every unrelated save.
|
|
37
|
+
|
|
38
|
+
A column listed in `encryptable` whose `@column({ type })` is `json` encrypts as
|
|
39
|
+
`encrypted:json`, so it round-trips as the structure it was instead of reaching
|
|
40
|
+
the cipher as `"[object Object]"`.
|
|
41
|
+
|
|
42
|
+
**`where()` on an encrypted column throws** rather than returning nothing. The
|
|
43
|
+
bind path runs a column's cast over the search value, which would encrypt it
|
|
44
|
+
under a fresh IV and compare it against ciphertext written with a different one:
|
|
45
|
+
zero rows, no error, and a screen reading "no such client" for a client who is
|
|
46
|
+
right there. `EncryptedColumnError` says so and points at a blind index.
|
|
47
|
+
|
|
48
|
+
**A value the key cannot open fails the read**, naming the model, the column and
|
|
49
|
+
the two causes (a rotated `APP_KEY`, or plaintext that predates the cast).
|
|
50
|
+
Returning the ciphertext instead would put an unreadable value where the
|
|
51
|
+
application expects a real one — displayed, reported on, or re-encrypted by the
|
|
52
|
+
next save, which destroys the original.
|
|
53
|
+
|
|
54
|
+
`migrate:generate` and `synchronize()` widen an encrypted column to TEXT
|
|
55
|
+
whatever it was declared as. A payload is ~1.4× the plaintext plus 28 bytes, and
|
|
56
|
+
MySQL outside strict mode truncates rather than failing — a truncated payload
|
|
57
|
+
never decrypts, so the row would be destroyed silently at write time.
|
|
58
|
+
|
|
11
59
|
## [1.3.0] — 2026-08-09
|
|
12
60
|
|
|
13
61
|
### Changed — BREAKING
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/orm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@zerotal/core": "1.
|
|
34
|
-
"@zerotal/validator": "1.
|
|
33
|
+
"@zerotal/core": "1.4.0",
|
|
34
|
+
"@zerotal/validator": "1.4.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"typescript": "^5.8.0"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted columns — ciphertext at rest, plaintext on the model.
|
|
3
|
+
*
|
|
4
|
+
* The column stores an opaque AES-256-GCM payload keyed by `APP_KEY`; the model
|
|
5
|
+
* property holds the value you assigned. Encryption happens on the way to the
|
|
6
|
+
* database and decryption on the way back, so nothing in between — your code,
|
|
7
|
+
* validation, `$dirty` — has to know the column is encrypted.
|
|
8
|
+
*
|
|
9
|
+
* Two ways to declare one, and they compile to the same thing:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* class Client extends BaseModel {
|
|
13
|
+
* @column({ type: "text", nullable: true, cast: "encrypted" })
|
|
14
|
+
* idNumber?: string;
|
|
15
|
+
*
|
|
16
|
+
* // …or, for several columns at once:
|
|
17
|
+
* static encryptable = ["idNumber", "passportNumber"];
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* **The column must be `text`, not `string`.** A payload is roughly a third
|
|
22
|
+
* larger than its plaintext plus 28 bytes of IV and auth tag, so a 13-character
|
|
23
|
+
* ID number lands around 60 characters and a paragraph overflows a `VARCHAR(255)`
|
|
24
|
+
* that comfortably held it before.
|
|
25
|
+
*
|
|
26
|
+
* **You cannot query an encrypted column.** Every write draws a fresh IV, so the
|
|
27
|
+
* same value encrypts to different ciphertext each time and an equality match can
|
|
28
|
+
* never hit. `where()` on one throws rather than returning zero rows — see
|
|
29
|
+
* {@link EncryptedColumnError}. If you need lookup, keep a separate hashed column
|
|
30
|
+
* (a blind index) beside it and query that.
|
|
31
|
+
*
|
|
32
|
+
* **Decryption failure is fatal to the read**, deliberately. Returning the
|
|
33
|
+
* ciphertext instead would put an unreadable value where the application expects
|
|
34
|
+
* a real one — displayed to a user, written into a report, or re-encrypted on the
|
|
35
|
+
* next save, which destroys the original for good.
|
|
36
|
+
*
|
|
37
|
+
* @packageDocumentation
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { ZerotalError } from "@zerotal/core";
|
|
41
|
+
import { Crypt } from "@zerotal/core/security";
|
|
42
|
+
|
|
43
|
+
/** The cast names that mean "encrypt this column". */
|
|
44
|
+
export type EncryptedCastName = "encrypted" | "encrypted:json";
|
|
45
|
+
|
|
46
|
+
/** Raised for anything an encrypted column cannot do. */
|
|
47
|
+
export class EncryptedColumnError extends ZerotalError {
|
|
48
|
+
constructor(message: string, code: string, context?: Record<string, unknown>) {
|
|
49
|
+
super(message, code, 500, context);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether a resolved cast option is one of the encrypted ones. */
|
|
54
|
+
export function isEncryptedCast(cast: unknown): cast is EncryptedCastName {
|
|
55
|
+
return cast === "encrypted" || cast === "encrypted:json";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Model value → the ciphertext written to the column.
|
|
60
|
+
*
|
|
61
|
+
* @param label - `Model.column`, or just the column name, for error messages.
|
|
62
|
+
*/
|
|
63
|
+
export function encryptColumn(value: unknown, cast: EncryptedCastName, label: string): unknown {
|
|
64
|
+
if (value === null || value === undefined) return value;
|
|
65
|
+
|
|
66
|
+
if (cast === "encrypted:json") return Crypt.encryptString(JSON.stringify(value));
|
|
67
|
+
|
|
68
|
+
if (typeof value !== "string") {
|
|
69
|
+
// Not coerced with String(). `42` would store as "42" and read back as the
|
|
70
|
+
// string "42" — the value's type silently changing between write and read,
|
|
71
|
+
// which is worse than refusing it, because nothing fails until something
|
|
72
|
+
// downstream compares it.
|
|
73
|
+
throw new EncryptedColumnError(
|
|
74
|
+
`[Zerotal] ${label} is cast "encrypted", which stores strings, but a ` +
|
|
75
|
+
`${Array.isArray(value) ? "array" : typeof value} was assigned. Use ` +
|
|
76
|
+
`cast: "encrypted:json" to encrypt a structured value — it round-trips the type.`,
|
|
77
|
+
"E_ENCRYPTED_COLUMN_NOT_A_STRING",
|
|
78
|
+
{ column: label, received: typeof value },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return Crypt.encryptString(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Stored ciphertext → the value the model exposes.
|
|
87
|
+
*
|
|
88
|
+
* @param label - `Model.column`, for error messages.
|
|
89
|
+
* @throws {@link EncryptedColumnError} When the stored value is not ciphertext
|
|
90
|
+
* this `APP_KEY` can open.
|
|
91
|
+
*/
|
|
92
|
+
export function decryptColumn(value: unknown, cast: EncryptedCastName, label: string): unknown {
|
|
93
|
+
if (value === null || value === undefined) return value;
|
|
94
|
+
|
|
95
|
+
let plain: string;
|
|
96
|
+
try {
|
|
97
|
+
plain = Crypt.decryptString(String(value));
|
|
98
|
+
} catch (cause) {
|
|
99
|
+
throw new EncryptedColumnError(
|
|
100
|
+
`[Zerotal] Could not decrypt ${label}. The column is cast "${cast}", so what is ` +
|
|
101
|
+
`stored has to be ciphertext this APP_KEY can open. Two things cause this: ` +
|
|
102
|
+
`APP_KEY changed since the row was written (decrypt with the old key and ` +
|
|
103
|
+
`re-save), or the column already held plaintext when the cast was added ` +
|
|
104
|
+
`(back-fill the existing rows before switching it on). The row cannot be read ` +
|
|
105
|
+
`until one of those is resolved.`,
|
|
106
|
+
"E_ENCRYPTED_COLUMN_UNREADABLE",
|
|
107
|
+
{ column: label, cause: cause instanceof Error ? cause.message : String(cause) },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (cast !== "encrypted:json") return plain;
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(plain);
|
|
114
|
+
} catch {
|
|
115
|
+
// Decrypted cleanly, so the key is right and the bytes are intact — the
|
|
116
|
+
// column simply was not written as JSON. Says so, rather than reporting a
|
|
117
|
+
// key problem it does not have.
|
|
118
|
+
throw new EncryptedColumnError(
|
|
119
|
+
`[Zerotal] ${label} decrypted, but its contents are not JSON. The column is cast ` +
|
|
120
|
+
`"encrypted:json"; a column written under plain "encrypted" reads back with that.`,
|
|
121
|
+
"E_ENCRYPTED_COLUMN_NOT_JSON",
|
|
122
|
+
{ column: label },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The error thrown when someone tries to filter on an encrypted column. */
|
|
128
|
+
export function encryptedQueryError(label: string): EncryptedColumnError {
|
|
129
|
+
return new EncryptedColumnError(
|
|
130
|
+
`[Zerotal] Cannot filter on ${label} — it is an encrypted column. Every write draws ` +
|
|
131
|
+
`a fresh IV, so the same value encrypts to different ciphertext each time and an ` +
|
|
132
|
+
`equality match can never hit. Keep a separate hashed lookup column (a blind index) ` +
|
|
133
|
+
`beside it and query that instead.`,
|
|
134
|
+
"E_ENCRYPTED_COLUMN_NOT_QUERYABLE",
|
|
135
|
+
{ column: label },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve `static encryptable = [...]` down a prototype chain into cast entries.
|
|
141
|
+
*
|
|
142
|
+
* Declaring it is exactly equivalent to putting `cast: "encrypted"` on each of
|
|
143
|
+
* those columns, which is why it resolves to casts here rather than being handled
|
|
144
|
+
* separately: read, write, `$dirty` and the query guard then all see one thing.
|
|
145
|
+
*
|
|
146
|
+
* A `json` column resolves to `encrypted:json` on its own — the alternative is
|
|
147
|
+
* `String(someObject)` reaching the cipher as `"[object Object]"`.
|
|
148
|
+
*
|
|
149
|
+
* Entries union down the chain, so a base model marking a column encrypted keeps
|
|
150
|
+
* it encrypted in a subclass that lists its own.
|
|
151
|
+
*
|
|
152
|
+
* @param chain - The constructor chain, base-most first.
|
|
153
|
+
* @param columnType - Declared `@column({ type })` for a property, if any.
|
|
154
|
+
*/
|
|
155
|
+
export function collectEncryptable(
|
|
156
|
+
chain: readonly object[],
|
|
157
|
+
columnType: (key: string) => string | undefined,
|
|
158
|
+
): Record<string, EncryptedCastName> {
|
|
159
|
+
const out: Record<string, EncryptedCastName> = {};
|
|
160
|
+
for (const entry of chain) {
|
|
161
|
+
const keys = (entry as { encryptable?: string[] }).encryptable;
|
|
162
|
+
if (!keys) continue;
|
|
163
|
+
for (const key of keys) {
|
|
164
|
+
out[key] = columnType(key) === "json" ? "encrypted:json" : "encrypted";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -244,6 +244,11 @@ export type { DatabaseConfigShape } from "./config.ts";
|
|
|
244
244
|
// Casts
|
|
245
245
|
export { Cast, JsonCast, ArrayCast, json, objectOf, arrayOf } from "./casts/Cast.ts";
|
|
246
246
|
export type { CastContract, CastMapper, CastField } from "./casts/Cast.ts";
|
|
247
|
+
// Encrypted columns — `cast: "encrypted"` / `static encryptable`. The error is
|
|
248
|
+
// exported so an app can catch an unreadable row (a rotated APP_KEY) and say
|
|
249
|
+
// something useful instead of 500ing.
|
|
250
|
+
export { EncryptedColumnError, isEncryptedCast } from "./casts/encrypted.ts";
|
|
251
|
+
export type { EncryptedCastName } from "./casts/encrypted.ts";
|
|
247
252
|
|
|
248
253
|
// Framework events emitted by the ORM (subscribe via core's FrameworkEvents bus).
|
|
249
254
|
export {
|
package/src/model/BaseModel.ts
CHANGED
|
@@ -36,6 +36,13 @@ import { installReactiveAccessors, type ColumnOptions } from "./decorators/colum
|
|
|
36
36
|
import { _compose } from "./mixins.ts";
|
|
37
37
|
import type { Compose } from "./mixins.ts";
|
|
38
38
|
import { columnsFor, relationsFor } from "./decorators/_metadata.ts";
|
|
39
|
+
import {
|
|
40
|
+
collectEncryptable,
|
|
41
|
+
decryptColumn,
|
|
42
|
+
encryptColumn,
|
|
43
|
+
isEncryptedCast,
|
|
44
|
+
type EncryptedCastName,
|
|
45
|
+
} from "../casts/encrypted.ts";
|
|
39
46
|
import { TransactionContext } from "../db/TransactionContext.ts";
|
|
40
47
|
import type { InsertPayload, UpdatePayload, FillablePayload } from "./payload.ts";
|
|
41
48
|
import type { WhereOperator, OrderDirection } from "../db/types.ts";
|
|
@@ -222,6 +229,7 @@ type StringCast =
|
|
|
222
229
|
| "float"
|
|
223
230
|
| "enum"
|
|
224
231
|
| "immutable_datetime"
|
|
232
|
+
| EncryptedCastName
|
|
225
233
|
| `decimal:${number}`;
|
|
226
234
|
type CastOption = ColumnOptions["cast"];
|
|
227
235
|
|
|
@@ -234,6 +242,13 @@ function getCasts(ctor: Function): Record<string, CastOption> {
|
|
|
234
242
|
current = Object.getPrototypeOf(current) as Function | null;
|
|
235
243
|
}
|
|
236
244
|
chain.reverse();
|
|
245
|
+
// `static encryptable` first, so an explicit cast on the same column still wins —
|
|
246
|
+
// spelling one out is the more specific statement of intent.
|
|
247
|
+
const colReg = columnsFor(ctor);
|
|
248
|
+
Object.assign(
|
|
249
|
+
merged,
|
|
250
|
+
collectEncryptable(chain, (key) => colReg?.get(key)?.type),
|
|
251
|
+
);
|
|
237
252
|
for (const entry of chain) {
|
|
238
253
|
const casts = (entry as { casts?: Record<string, CastOption> }).casts;
|
|
239
254
|
if (casts) Object.assign(merged, casts);
|
|
@@ -241,8 +256,9 @@ function getCasts(ctor: Function): Record<string, CastOption> {
|
|
|
241
256
|
return merged;
|
|
242
257
|
}
|
|
243
258
|
|
|
244
|
-
function applyCastGet(value: unknown, cast: StringCast): unknown {
|
|
259
|
+
function applyCastGet(value: unknown, cast: StringCast, label: string): unknown {
|
|
245
260
|
if (value === null || value === undefined) return value;
|
|
261
|
+
if (isEncryptedCast(cast)) return decryptColumn(value, cast, label);
|
|
246
262
|
const cstr = cast as unknown as string;
|
|
247
263
|
if (cstr.startsWith("decimal:")) {
|
|
248
264
|
const n = parseInt(cstr.slice(8), 10) || 0;
|
|
@@ -294,8 +310,9 @@ function tryParseJson(s: string): unknown {
|
|
|
294
310
|
}
|
|
295
311
|
}
|
|
296
312
|
|
|
297
|
-
function applyCastSet(value: unknown, cast: StringCast): unknown {
|
|
313
|
+
function applyCastSet(value: unknown, cast: StringCast, label: string): unknown {
|
|
298
314
|
if (value === null || value === undefined) return value;
|
|
315
|
+
if (isEncryptedCast(cast)) return encryptColumn(value, cast, label);
|
|
299
316
|
const cstr = cast as unknown as string;
|
|
300
317
|
if (cstr.startsWith("decimal:")) {
|
|
301
318
|
const n = parseInt(cstr.slice(8), 10) || 0;
|
|
@@ -362,6 +379,7 @@ function _serializeForWrite(
|
|
|
362
379
|
val: unknown,
|
|
363
380
|
casts: Record<string, CastOption>,
|
|
364
381
|
colReg: Map<string, ColumnOptions> | null,
|
|
382
|
+
model?: string,
|
|
365
383
|
): unknown {
|
|
366
384
|
const colMeta = colReg?.get(key);
|
|
367
385
|
const castOpt = casts[key] ?? colMeta?.cast;
|
|
@@ -370,7 +388,7 @@ function _serializeForWrite(
|
|
|
370
388
|
if (castOpt && typeof castOpt === "object" && castOpt.set) {
|
|
371
389
|
serializedVal = castOpt.set(val);
|
|
372
390
|
} else if (typeof castOpt === "string") {
|
|
373
|
-
serializedVal = applyCastSet(val, castOpt);
|
|
391
|
+
serializedVal = applyCastSet(val, castOpt, model ? `${model}.${key}` : key);
|
|
374
392
|
} else if (colType === "boolean" && val !== null && val !== undefined) {
|
|
375
393
|
serializedVal = val ? 1 : 0;
|
|
376
394
|
} else if (colType === "json" && val !== null) {
|
|
@@ -833,6 +851,42 @@ export class BaseModel {
|
|
|
833
851
|
*/
|
|
834
852
|
static hashable?: string[];
|
|
835
853
|
|
|
854
|
+
/**
|
|
855
|
+
* Columns encrypted at rest with AES-256-GCM under `APP_KEY`, and decrypted
|
|
856
|
+
* transparently on read. Shorthand for putting `cast: "encrypted"` on each one.
|
|
857
|
+
*
|
|
858
|
+
* Unlike {@link hashable} this is reversible and non-destructive: the model
|
|
859
|
+
* property still holds the value you assigned after a `save()`, because the
|
|
860
|
+
* encryption happens on the way to the database rather than to the instance.
|
|
861
|
+
* `$dirty` therefore compares plaintext, and an unchanged column is not
|
|
862
|
+
* rewritten with a new IV on every save.
|
|
863
|
+
*
|
|
864
|
+
* A `json` column in this list encrypts as `encrypted:json`, so it round-trips
|
|
865
|
+
* as the structure it was rather than as `"[object Object]"`.
|
|
866
|
+
*
|
|
867
|
+
* @example
|
|
868
|
+
* ```ts
|
|
869
|
+
* class Client extends BaseModel {
|
|
870
|
+
* static encryptable = ["idNumber", "passportNumber"];
|
|
871
|
+
*
|
|
872
|
+
* // TEXT, not VARCHAR — a payload outgrows its plaintext.
|
|
873
|
+
* @column({ type: "text", nullable: true }) idNumber?: string;
|
|
874
|
+
* @column({ type: "text", nullable: true }) passportNumber?: string;
|
|
875
|
+
* }
|
|
876
|
+
* ```
|
|
877
|
+
*
|
|
878
|
+
* @remarks
|
|
879
|
+
* Encrypted columns cannot be filtered, grouped or usefully sorted — every
|
|
880
|
+
* write draws a fresh IV, so the ciphertext for a given value never repeats.
|
|
881
|
+
* `where()` on one throws rather than quietly matching nothing. For lookup,
|
|
882
|
+
* keep a hashed blind-index column beside it. Add these to {@link hidden} too
|
|
883
|
+
* if the model is serialized to a client: decryption puts the real value back
|
|
884
|
+
* on the instance, and `toJSON()` will happily include it.
|
|
885
|
+
*
|
|
886
|
+
* @category Persistence
|
|
887
|
+
*/
|
|
888
|
+
static encryptable?: string[];
|
|
889
|
+
|
|
836
890
|
/**
|
|
837
891
|
* Register an observer class for this model.
|
|
838
892
|
* The observer's lifecycle methods (creating, created, updating, …) are
|
|
@@ -1480,7 +1534,7 @@ export class BaseModel {
|
|
|
1480
1534
|
const row: Record<string, unknown> = {};
|
|
1481
1535
|
for (const [key, val] of Object.entries(rec as Record<string, unknown>)) {
|
|
1482
1536
|
if (key.startsWith("_")) continue;
|
|
1483
|
-
row[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
|
|
1537
|
+
row[toSnake(key)] = _serializeForWrite(key, val, casts, colReg, ModelClass.name);
|
|
1484
1538
|
}
|
|
1485
1539
|
if (useTs) {
|
|
1486
1540
|
row["created_at"] = now;
|
|
@@ -1562,7 +1616,7 @@ export class BaseModel {
|
|
|
1562
1616
|
const row: Record<string, unknown> = _writeDialect.run(dialect, () => {
|
|
1563
1617
|
const r: Record<string, unknown> = {};
|
|
1564
1618
|
for (const [key, val] of Object.entries(data as Record<string, unknown>)) {
|
|
1565
|
-
r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg);
|
|
1619
|
+
r[toSnake(key)] = _serializeForWrite(key, val, casts, colReg, this.name);
|
|
1566
1620
|
}
|
|
1567
1621
|
return r;
|
|
1568
1622
|
});
|
|
@@ -1703,7 +1757,7 @@ export class BaseModel {
|
|
|
1703
1757
|
// value is readable straight after save() without a reload.
|
|
1704
1758
|
self[key] = effective;
|
|
1705
1759
|
}
|
|
1706
|
-
r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg);
|
|
1760
|
+
r[toSnake(key)] = _serializeForWrite(key, effective, casts, colReg, ModelClass.name);
|
|
1707
1761
|
}
|
|
1708
1762
|
if (ModelClass.timestamps) {
|
|
1709
1763
|
const now = _serializeDate(new Date());
|
|
@@ -1785,7 +1839,11 @@ export class BaseModel {
|
|
|
1785
1839
|
if (Object.keys(dirty).length > 0) {
|
|
1786
1840
|
const entries = _writeDialect.run(dialect, () =>
|
|
1787
1841
|
Object.entries(dirty).map(
|
|
1788
|
-
([k, v]) =>
|
|
1842
|
+
([k, v]) =>
|
|
1843
|
+
[toSnake(k), _serializeForWrite(k, v, casts, colReg, ModelClass.name)] as [
|
|
1844
|
+
string,
|
|
1845
|
+
unknown,
|
|
1846
|
+
],
|
|
1789
1847
|
),
|
|
1790
1848
|
);
|
|
1791
1849
|
const segs: Seg[] = [`UPDATE ${ModelClass.table} SET `];
|
|
@@ -2467,7 +2525,7 @@ function _applyRow(inst: BaseModel, row: Record<string, unknown>): void {
|
|
|
2467
2525
|
finalVal = castObj.get(rawVal);
|
|
2468
2526
|
} else if (typeof cast === "string") {
|
|
2469
2527
|
// Explicit shorthand cast ('boolean', 'json', 'date', etc.)
|
|
2470
|
-
finalVal = applyCastGet(rawVal, cast);
|
|
2528
|
+
finalVal = applyCastGet(rawVal, cast, `${ModelClass.name}.${propKey}`);
|
|
2471
2529
|
} else if (colType === "boolean" && rawVal !== null && rawVal !== undefined) {
|
|
2472
2530
|
// Auto-cast based on @column({ type: 'boolean' }) — SQLite stores 0/1
|
|
2473
2531
|
finalVal = rawVal === 1 || rawVal === "1" || rawVal === true;
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
toSnakeColumn as _toSnakeColumn,
|
|
7
7
|
ctorChain,
|
|
8
8
|
} from "../support/identifiers.ts";
|
|
9
|
+
import { collectEncryptable, encryptedQueryError, isEncryptedCast } from "../casts/encrypted.ts";
|
|
9
10
|
import type {
|
|
10
11
|
PaginateResult,
|
|
11
12
|
SimplePaginateResult,
|
|
@@ -27,6 +28,14 @@ type CastOption = ColumnOptions["cast"];
|
|
|
27
28
|
|
|
28
29
|
function _getCasts(ctor: Function): Record<string, CastOption> {
|
|
29
30
|
const merged: Record<string, CastOption> = {};
|
|
31
|
+
const colReg = columnsFor(ctor);
|
|
32
|
+
// Mirrors getCasts() in BaseModel: `static encryptable` resolves to casts, and an
|
|
33
|
+
// explicit cast on the same column wins. Without this the guard below cannot see
|
|
34
|
+
// a column declared encrypted through the list form.
|
|
35
|
+
Object.assign(
|
|
36
|
+
merged,
|
|
37
|
+
collectEncryptable(ctorChain(ctor), (key) => colReg?.get(key)?.type),
|
|
38
|
+
);
|
|
30
39
|
for (const entry of ctorChain(ctor)) {
|
|
31
40
|
const casts = (entry as { casts?: Record<string, CastOption> }).casts;
|
|
32
41
|
if (casts) Object.assign(merged, casts);
|
|
@@ -1245,6 +1254,15 @@ export class ModelQueryBuilder<M extends BaseModel> extends QueryBuilder {
|
|
|
1245
1254
|
const castOpt = casts[rawKey] ?? casts[camelKey] ?? colMeta?.cast;
|
|
1246
1255
|
const colType = colMeta?.type;
|
|
1247
1256
|
|
|
1257
|
+
// Before anything binds: an encrypted column cannot be compared. Running the
|
|
1258
|
+
// cast's set() here would encrypt the search term under a fresh IV, producing
|
|
1259
|
+
// ciphertext that cannot equal what is stored — a query that always returns
|
|
1260
|
+
// nothing and never says why. Same failure the created_at note below describes,
|
|
1261
|
+
// and permanent rather than occasional, so it is refused outright.
|
|
1262
|
+
if (isEncryptedCast(castOpt)) {
|
|
1263
|
+
throw encryptedQueryError(`${this._ModelClass.name}.${camelKey}`);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1248
1266
|
if (operator === "in" || operator === "not in") {
|
|
1249
1267
|
if (Array.isArray(value)) {
|
|
1250
1268
|
return value.map((v) => this._coerceWhereValue(column, v));
|
|
@@ -29,6 +29,8 @@ export { columnRegistry };
|
|
|
29
29
|
* | `"date"` | `{ type: "datetime", cast: "date" }` |
|
|
30
30
|
* | `"json"` | `{ type: "json", cast: "json" }` |
|
|
31
31
|
* | `"array"` | `{ type: "json", cast: "array" }` |
|
|
32
|
+
* | `"encrypted"` | `{ type: "text", cast: "encrypted" }` |
|
|
33
|
+
* | `"encrypted:json"` | `{ type: "text", cast: "encrypted:json" }` |
|
|
32
34
|
*/
|
|
33
35
|
export type ColumnShorthand =
|
|
34
36
|
| "string"
|
|
@@ -40,7 +42,9 @@ export type ColumnShorthand =
|
|
|
40
42
|
| "datetime"
|
|
41
43
|
| "date"
|
|
42
44
|
| "json"
|
|
43
|
-
| "array"
|
|
45
|
+
| "array"
|
|
46
|
+
| "encrypted"
|
|
47
|
+
| "encrypted:json";
|
|
44
48
|
|
|
45
49
|
/**
|
|
46
50
|
* Full option object accepted by `@column({ ... })`.
|
|
@@ -105,6 +109,12 @@ export interface ColumnOptions {
|
|
|
105
109
|
* - 'integer' — parseInt on both read and write
|
|
106
110
|
* - 'float' — parseFloat on both read and write
|
|
107
111
|
* - 'enum' — pass-through; pairs with `enumValues` for TS enum columns
|
|
112
|
+
* - 'encrypted' — AES-256-GCM at rest under `APP_KEY`, plaintext on the model
|
|
113
|
+
* - 'encrypted:json' — the same, for a structured value (stringified, then encrypted)
|
|
114
|
+
*
|
|
115
|
+
* Encrypted columns need `type: "text"` (a payload outgrows the plaintext) and
|
|
116
|
+
* cannot be filtered on — `where()` against one throws, because a fresh IV per
|
|
117
|
+
* write means the ciphertext never repeats. See `casts/encrypted.ts`.
|
|
108
118
|
*/
|
|
109
119
|
cast?:
|
|
110
120
|
| "datetime"
|
|
@@ -116,6 +126,8 @@ export interface ColumnOptions {
|
|
|
116
126
|
| "float"
|
|
117
127
|
| "enum"
|
|
118
128
|
| "immutable_datetime"
|
|
129
|
+
| "encrypted"
|
|
130
|
+
| "encrypted:json"
|
|
119
131
|
| `decimal:${number}`
|
|
120
132
|
| {
|
|
121
133
|
get?: (dbValue: unknown) => unknown;
|
|
@@ -140,6 +152,10 @@ const SHORTHAND_MAP: Record<ColumnShorthand, ColumnOptions> = {
|
|
|
140
152
|
date: { type: "datetime", cast: "date" },
|
|
141
153
|
json: { type: "json", cast: "json" },
|
|
142
154
|
array: { type: "json", cast: "array" },
|
|
155
|
+
// TEXT, not string: the stored payload is ~1.4× the plaintext plus 28 bytes of
|
|
156
|
+
// IV and auth tag, so a VARCHAR that held the value will not hold its ciphertext.
|
|
157
|
+
encrypted: { type: "text", cast: "encrypted" },
|
|
158
|
+
"encrypted:json": { type: "text", cast: "encrypted:json" },
|
|
143
159
|
};
|
|
144
160
|
|
|
145
161
|
/**
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import type { ColumnOptions } from "../model/decorators/column.ts";
|
|
3
3
|
import { columnRegistry, columnsFor } from "../model/decorators/_metadata.ts";
|
|
4
|
+
import { ctorChain } from "../support/identifiers.ts";
|
|
5
|
+
import { isEncryptedCast } from "../casts/encrypted.ts";
|
|
4
6
|
|
|
5
7
|
// ── Model schema descriptor ───────────────────────────────────────────────────
|
|
6
8
|
|
|
@@ -54,12 +56,22 @@ function collectColumns(ctor: Function): Map<string, ColumnOptions> | null {
|
|
|
54
56
|
return columnsFor(ctor);
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
function toModelColumns(
|
|
59
|
+
function toModelColumns(
|
|
60
|
+
fields: Map<string, ColumnOptions>,
|
|
61
|
+
encrypted: ReadonlySet<string>,
|
|
62
|
+
): ModelColumn[] {
|
|
58
63
|
const columns: ModelColumn[] = [];
|
|
59
64
|
for (const [name, opts] of fields.entries()) {
|
|
60
65
|
columns.push({
|
|
61
66
|
name,
|
|
62
|
-
|
|
67
|
+
// An encrypted column is generated as TEXT whatever it was declared as. The
|
|
68
|
+
// stored payload is the plaintext plus 28 bytes of IV and auth tag, base64'd —
|
|
69
|
+
// about 1.4× longer — so a VARCHAR(255) that comfortably held the value no
|
|
70
|
+
// longer holds its ciphertext. MySQL outside strict mode truncates rather than
|
|
71
|
+
// failing, and a truncated payload will not decrypt: the row is lost, quietly,
|
|
72
|
+
// at write time. The generated migration says `table.text(...)`, so the
|
|
73
|
+
// widening is visible in review rather than only here.
|
|
74
|
+
type: encrypted.has(name) ? "text" : opts.type,
|
|
63
75
|
nullable: opts.nullable ?? false,
|
|
64
76
|
primary: opts.primary ?? false,
|
|
65
77
|
default: opts.default,
|
|
@@ -70,6 +82,21 @@ function toModelColumns(fields: Map<string, ColumnOptions>): ModelColumn[] {
|
|
|
70
82
|
return columns;
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
/** Columns encrypted by either route — `cast: "encrypted…"` or `static encryptable`. */
|
|
86
|
+
function encryptedColumns(
|
|
87
|
+
chain: readonly object[],
|
|
88
|
+
fields: Map<string, ColumnOptions>,
|
|
89
|
+
): Set<string> {
|
|
90
|
+
const names = new Set<string>();
|
|
91
|
+
for (const [name, opts] of fields.entries()) {
|
|
92
|
+
if (isEncryptedCast(opts.cast)) names.add(name);
|
|
93
|
+
}
|
|
94
|
+
for (const entry of chain) {
|
|
95
|
+
for (const key of (entry as { encryptable?: string[] }).encryptable ?? []) names.add(key);
|
|
96
|
+
}
|
|
97
|
+
return names;
|
|
98
|
+
}
|
|
99
|
+
|
|
73
100
|
// ── ModelInspector ────────────────────────────────────────────────────────────
|
|
74
101
|
|
|
75
102
|
/**
|
|
@@ -133,7 +160,7 @@ export const ModelInspector = {
|
|
|
133
160
|
primaryKey: (M["primaryKey"] as string | undefined) ?? "id",
|
|
134
161
|
timestamps: (M["timestamps"] as boolean | undefined) ?? true,
|
|
135
162
|
softDeletes: (M["softDeletes"] as boolean | undefined) ?? false,
|
|
136
|
-
columns: toModelColumns(fields),
|
|
163
|
+
columns: toModelColumns(fields, encryptedColumns(ctorChain(ctor), fields)),
|
|
137
164
|
};
|
|
138
165
|
},
|
|
139
166
|
};
|