bazis 0.102.0 → 0.104.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/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/codex.ts +8 -2
- package/core/infra/index.ts +1 -1
- package/core/orm/buildOrmProviders.ts +17 -7
- package/core/orm/encryptedWith.ts +85 -0
- package/core/orm/index.ts +1 -0
- package/library/orm/Metadata/ModelBuilder.ts +9 -0
- package/library/orm/Metadata/ValueConverter.ts +50 -27
- package/library/orm/Providers/propertyConversion.ts +22 -3
- package/library/orm/Query/SqlTranslator.ts +7 -3
- package/library/orm/Query/materialize.ts +2 -2
- package/library/orm/index.ts +1 -1
- package/package.json +1 -1
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.104.0" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
|
@@ -5,12 +5,18 @@ import { reader, requireValue } from "../connectorConfig";
|
|
|
5
5
|
import { CodexAppServerClient } from "./codex/CodexAppServerClient";
|
|
6
6
|
import type { CodexClient, CodexConfigShape } from "./codex/contracts";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Codex App Server client of the `codexAppServerConnect()` connector,
|
|
10
|
+
* injected by type like the other infra clients:
|
|
11
|
+
* `constructor(private readonly codex: ICodex)`.
|
|
12
|
+
*/
|
|
13
|
+
export interface ICodex extends CodexClient {}
|
|
14
|
+
export const ICodex = createToken<ICodex>("ICodex") as InjectionToken<CodexClient>;
|
|
9
15
|
|
|
10
16
|
/** App Server is a full executor; it does not implement the raw AgentModelProvider port. */
|
|
11
17
|
export function codexAppServerConnect(config: AppConfig<CodexConfigShape>): InfraConnector<CodexClient> {
|
|
12
18
|
return {
|
|
13
|
-
token:
|
|
19
|
+
token: ICodex, config, phase: 0,
|
|
14
20
|
create(configs) {
|
|
15
21
|
const view = reader(config, configs);
|
|
16
22
|
return new CodexAppServerClient({
|
package/core/infra/index.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { Infra, infraModule, type InfraManifest } from "./Infra";
|
|
|
7
7
|
export { InfraError, errorMessage, type ConnectionDescription, type InfraConnector } from "./InfraConnector";
|
|
8
8
|
export { InfraLifecycle } from "./InfraLifecycle";
|
|
9
9
|
export { reader, requireValue, settingNames, type ConfigReader } from "./connectorConfig";
|
|
10
|
-
export { codexAppServerConnect,
|
|
10
|
+
export { codexAppServerConnect, ICodex } from "./connectors/codex";
|
|
11
11
|
export { CodexError, RU_CODEX_MESSAGES, type CodexErrorCode, CODEX_MODEL_ID, CODEX_REASONING_EFFORT, type CodexClient, type CodexConfigShape, type CodexLogin, type CodexLoginMethod, type CodexModel, type CodexStatus, type CodexRunInput, type CodexChatMessage, type CodexTool, type CodexToolCall, type CodexToolResult } from "./connectors/codex/contracts";
|
|
12
12
|
|
|
13
13
|
export { postgres, IPostgres, postgresConnectionOptions, type PostgresConfigShape, type PostgresSslMode, type PostgresConnectorOptions } from "./connectors/postgres";
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { DI, HOSTED_SERVICE, type InjectionToken, type ModuleOwnedContributionSnapshot, type ProviderDefinition, type ProviderDependencyList, type ServiceResolver } from "../di";
|
|
2
2
|
import { getGeneratedClassDeps } from "../di/module/autoDeps";
|
|
3
3
|
import { resolveDependencyList } from "../di/internal/resolveDependencyList";
|
|
4
|
-
import { HEALTH_CHECK, StartupReport } from "../kernel";
|
|
4
|
+
import { ConfigRegistry, HEALTH_CHECK, StartupReport } from "../kernel";
|
|
5
5
|
import { DbContextOptions, OrmError, type DatabaseProvider, type DbContext } from "../../library/orm";
|
|
6
|
+
import { bindEncryptedColumns } from "./encryptedWith";
|
|
6
7
|
import { OrmLifecycle } from "./OrmLifecycle";
|
|
7
8
|
import { ownedStoreLifecycleFor, ownedStoreLifecycleHealthFor } from "./OrmOwnedStoreLifecycle";
|
|
8
9
|
import { effectiveOrmModuleConfig, ownedStoreRegistrations } from "./ownedStoreContributions";
|
|
@@ -22,13 +23,22 @@ type OrmBuildConfig<TContext extends DbContext> = OrmModuleConfig<TContext> & {
|
|
|
22
23
|
function createContextOptions<TContext extends DbContext>(
|
|
23
24
|
config: OrmBuildConfig<TContext>,
|
|
24
25
|
provider: DatabaseProvider,
|
|
26
|
+
serviceProvider: unknown,
|
|
25
27
|
): DbContextOptions {
|
|
26
|
-
return new DbContextOptions({
|
|
28
|
+
return bindKeys(new DbContextOptions({
|
|
27
29
|
provider,
|
|
28
30
|
entities: config.entities ?? [],
|
|
29
31
|
validateOnSave: config.validateOnSave,
|
|
30
32
|
executionStrategy: config.executionStrategy,
|
|
31
|
-
});
|
|
33
|
+
}), serviceProvider);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Binds the keys of `encryptedWith` columns from the application configuration. */
|
|
37
|
+
function bindKeys(options: DbContextOptions, serviceProvider: unknown): DbContextOptions {
|
|
38
|
+
const services = serviceProvider as { has?(token: unknown): boolean; resolve?(token: unknown): unknown };
|
|
39
|
+
const registry = services.has?.(ConfigRegistry) ? services.resolve?.(ConfigRegistry) as ConfigRegistry : undefined;
|
|
40
|
+
bindEncryptedColumns(options, registry);
|
|
41
|
+
return options;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
const contextDependencies = new WeakMap<object, ProviderDependencyList>();
|
|
@@ -103,7 +113,7 @@ export function buildOrmModuleProviders<TContext extends DbContext>(
|
|
|
103
113
|
DI.scoped(
|
|
104
114
|
DI.factoryProviderWithResolver(config.context, [token, SERVICE_PROVIDER] as const, (resolver, provider: DatabaseProvider, serviceProvider) => {
|
|
105
115
|
const current = effectiveConfigFor(serviceProvider, registrationIdentity, config);
|
|
106
|
-
return constructContext(current.context, createContextOptions(current, provider), resolver);
|
|
116
|
+
return constructContext(current.context, createContextOptions(current, provider, serviceProvider), resolver);
|
|
107
117
|
}),
|
|
108
118
|
),
|
|
109
119
|
DI.singleton(
|
|
@@ -119,7 +129,7 @@ export function buildOrmModuleProviders<TContext extends DbContext>(
|
|
|
119
129
|
});
|
|
120
130
|
return ownedStoreLifecycleFor(container, provider, records);
|
|
121
131
|
}
|
|
122
|
-
return createOrmLifecycle(current, createContextOptions(current, provider), false, serviceProvider);
|
|
132
|
+
return createOrmLifecycle(current, createContextOptions(current, provider, serviceProvider), false, serviceProvider);
|
|
123
133
|
}),
|
|
124
134
|
),
|
|
125
135
|
];
|
|
@@ -137,12 +147,12 @@ export function buildOrmModuleProviders<TContext extends DbContext>(
|
|
|
137
147
|
DI.scoped(DI.factoryProviderWithResolver(config.context, [SERVICE_PROVIDER], (resolver, serviceProvider) => {
|
|
138
148
|
const current = effectiveConfigFor(serviceProvider, registrationIdentity, config);
|
|
139
149
|
const context = current === config ? config.context : current.context;
|
|
140
|
-
return constructContext(context, current === config && optionsAtModuleCall !== undefined ? optionsAtModuleCall : createContextOptions(current, provider), resolver);
|
|
150
|
+
return constructContext(context, current === config && optionsAtModuleCall !== undefined ? bindKeys(optionsAtModuleCall, serviceProvider) : createContextOptions(current, provider, serviceProvider), resolver);
|
|
141
151
|
})),
|
|
142
152
|
// Standalone mode: the context owns its provider and closes it.
|
|
143
153
|
DI.singleton(DI.factoryProvider(HOSTED_SERVICE, [SERVICE_PROVIDER], (serviceProvider) => {
|
|
144
154
|
const current = effectiveConfigFor(serviceProvider, registrationIdentity, config);
|
|
145
|
-
return createOrmLifecycle(current, current === config && optionsAtModuleCall !== undefined ? optionsAtModuleCall : createContextOptions(current, provider), true, serviceProvider);
|
|
155
|
+
return createOrmLifecycle(current, current === config && optionsAtModuleCall !== undefined ? bindKeys(optionsAtModuleCall, serviceProvider) : createContextOptions(current, provider, serviceProvider), true, serviceProvider);
|
|
146
156
|
})),
|
|
147
157
|
];
|
|
148
158
|
return providers;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Secret, type ConfigDefinition, type ConfigRegistry } from "../kernel";
|
|
3
|
+
import { OrmError, type DbContextOptions, type ValueConverter } from "../../library/orm";
|
|
4
|
+
import { aesGcmConverter } from "../../library/orm/Metadata/ValueConverter";
|
|
5
|
+
|
|
6
|
+
interface PendingKey {
|
|
7
|
+
readonly config: ConfigDefinition<object>;
|
|
8
|
+
readonly field: string;
|
|
9
|
+
/** The secret once bound, the configuration it came from, and its fingerprint for a second binding. */
|
|
10
|
+
secret?: string;
|
|
11
|
+
registry?: ConfigRegistry;
|
|
12
|
+
fingerprint?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const pending = new WeakMap<object, PendingKey>();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* AES-256-GCM encryption of a string column with a key from the application
|
|
19
|
+
* configuration. The key is read when the ORM starts — after every
|
|
20
|
+
* configuration source, secret files included, is loaded — so a missing key
|
|
21
|
+
* stops the start instead of failing on the first request:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* export const cryptoConfig = defineConfig("crypto", { default: { phoneKey: secret("dev-…") }, production: { phoneKey: secret() } });
|
|
25
|
+
*
|
|
26
|
+
* @HasConversion(encryptedWith(cryptoConfig, "phoneKey"))
|
|
27
|
+
* @Column({ type: "text" })
|
|
28
|
+
* phone = "";
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* The secret must be random (`openssl rand -base64 32`): it is hashed with
|
|
32
|
+
* SHA-256, not stretched like a password. Equal values are stored differently,
|
|
33
|
+
* so the column cannot be compared or sorted in SQL.
|
|
34
|
+
*/
|
|
35
|
+
export function encryptedWith<T extends object>(config: ConfigDefinition<T>, field: keyof T & string): ValueConverter<string, string> {
|
|
36
|
+
const binding: PendingKey = { config: config as ConfigDefinition<object>, field };
|
|
37
|
+
const converter = aesGcmConverter(() => {
|
|
38
|
+
if (binding.secret === undefined) {
|
|
39
|
+
throw new OrmError(`encryptedWith(${label(binding)}): the key is not bound yet; the column is used outside a started application.`);
|
|
40
|
+
}
|
|
41
|
+
return binding.secret;
|
|
42
|
+
});
|
|
43
|
+
pending.set(converter, binding);
|
|
44
|
+
return converter;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Binds the keys of `encryptedWith` columns of a context to the application
|
|
49
|
+
* configuration. Called by the ORM providers at startup; binding the same
|
|
50
|
+
* column again with another key (two applications in one process) is refused.
|
|
51
|
+
*/
|
|
52
|
+
export function bindEncryptedColumns(options: DbContextOptions, registry: ConfigRegistry | undefined): void {
|
|
53
|
+
for (const entity of options.model.entities) {
|
|
54
|
+
for (const property of entity.properties) {
|
|
55
|
+
const binding = property.converter === undefined ? undefined : pending.get(property.converter);
|
|
56
|
+
if (binding === undefined || (registry !== undefined && binding.registry === registry)) continue;
|
|
57
|
+
const where = `${entity.name}.${property.propertyName}: encryptedWith(${label(binding)})`;
|
|
58
|
+
if (registry === undefined) throw new OrmError(`${where}: the application configuration is not available.`);
|
|
59
|
+
let view: { get(key: string): unknown };
|
|
60
|
+
try {
|
|
61
|
+
view = registry.get(binding.config) as { get(key: string): unknown };
|
|
62
|
+
} catch {
|
|
63
|
+
throw new OrmError(`${where}: the config "${prefixOf(binding)}" is not declared in the application; add it to \`config\` of the module that owns ${entity.name} or to runApp({ config }).`);
|
|
64
|
+
}
|
|
65
|
+
const value = view.get(binding.field);
|
|
66
|
+
const secret = value instanceof Secret ? value.reveal() : value;
|
|
67
|
+
if (typeof secret !== "string" || secret.length === 0) throw new OrmError(`${where}: the key is empty; set a random secret (openssl rand -base64 32).`);
|
|
68
|
+
const fingerprint = createHash("sha256").update(secret).digest("hex");
|
|
69
|
+
if (binding.fingerprint !== undefined && binding.fingerprint !== fingerprint) {
|
|
70
|
+
throw new OrmError(`${where}: the column is already bound to another key in this process.`);
|
|
71
|
+
}
|
|
72
|
+
binding.secret = secret;
|
|
73
|
+
binding.registry = registry;
|
|
74
|
+
binding.fingerprint = fingerprint;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function prefixOf(binding: PendingKey): string {
|
|
80
|
+
return binding.config.token.description.replace(/^Config:/, "");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function label(binding: PendingKey): string {
|
|
84
|
+
return `${prefixOf(binding)}.${binding.field}`;
|
|
85
|
+
}
|
package/core/orm/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export { IRepository, repositoryFor } from "./repository";
|
|
|
11
11
|
export { registerRepositories } from "./registerRepositories";
|
|
12
12
|
export { paginate, type PageResult } from "./listQuery";
|
|
13
13
|
export { databaseErrorStatus } from "./databaseErrorStatus";
|
|
14
|
+
export { encryptedWith } from "./encryptedWith";
|
|
14
15
|
|
|
15
16
|
import { registerModuleMetadataExpander, type BazisModuleRef } from "../di";
|
|
16
17
|
import { registerModuleDescriber } from "../kernel/report/moduleTree";
|
|
@@ -184,6 +184,15 @@ export class ModelBuilder {
|
|
|
184
184
|
if (indexNames.has(index.name)) throw new ModelBuildError(`Entity "${ctor.name}": duplicate index name "${index.name}".`);
|
|
185
185
|
indexNames.add(index.name);
|
|
186
186
|
}
|
|
187
|
+
// Encryption with a random nonce stores equal values differently: such a
|
|
188
|
+
// column cannot identify a row or be unique.
|
|
189
|
+
for (const property of properties) {
|
|
190
|
+
if (property.converter?.comparable !== false) continue;
|
|
191
|
+
if (property.isKey) throw new ModelBuildError(`Entity "${ctor.name}": key "${property.propertyName}" cannot be encrypted: equal values are stored differently.`);
|
|
192
|
+
if (indexes.some((index) => index.unique && index.columns.includes(property.columnName))) {
|
|
193
|
+
throw new ModelBuildError(`Entity "${ctor.name}": a unique index on encrypted "${property.propertyName}" cannot work: equal values are stored differently. Put it on a keyed hash (HMAC) column.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
187
196
|
return model;
|
|
188
197
|
}
|
|
189
198
|
|
|
@@ -7,40 +7,63 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:
|
|
|
7
7
|
export interface ValueConverter<TModel = unknown, TProvider = unknown> {
|
|
8
8
|
toProvider(value: TModel): TProvider;
|
|
9
9
|
fromProvider(value: TProvider): TModel;
|
|
10
|
+
/**
|
|
11
|
+
* `false`: equal values are stored differently (encryption with a random
|
|
12
|
+
* nonce), so a condition or an ordering on the column cannot work in SQL;
|
|
13
|
+
* the query fails instead of silently matching nothing.
|
|
14
|
+
*/
|
|
15
|
+
readonly comparable?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Thrown by an encrypted column that cannot be read: another key, or the stored value was changed. */
|
|
19
|
+
export class DecryptionError extends Error {
|
|
20
|
+
constructor() {
|
|
21
|
+
super("cannot decrypt the value: it was encrypted with another key, or the stored value was changed");
|
|
22
|
+
this.name = "DecryptionError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* AES-256-GCM string encryption with a key that is read on first use.
|
|
28
|
+
* The database stores base64(nonce|ciphertext|tag); the key is the SHA-256 of
|
|
29
|
+
* the secret, so the secret must be random (32 bytes), not a password.
|
|
30
|
+
*/
|
|
31
|
+
export function aesGcmConverter(secret: () => string): ValueConverter<string, string> {
|
|
32
|
+
let key: Buffer | undefined;
|
|
33
|
+
const keyOf = (): Buffer => (key ??= createHash("sha256").update(secret()).digest());
|
|
34
|
+
return {
|
|
35
|
+
comparable: false,
|
|
36
|
+
toProvider(plaintext: string): string {
|
|
37
|
+
const iv = randomBytes(12);
|
|
38
|
+
const cipher = createCipheriv("aes-256-gcm", keyOf(), iv);
|
|
39
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
40
|
+
return Buffer.concat([iv, encrypted, cipher.getAuthTag()]).toString("base64");
|
|
41
|
+
},
|
|
42
|
+
fromProvider(stored: string): string {
|
|
43
|
+
// Before 0.104.0 an empty string was stored as is.
|
|
44
|
+
if (stored === "") return "";
|
|
45
|
+
const buf = Buffer.from(stored, "base64");
|
|
46
|
+
if (buf.length < 28) throw new DecryptionError();
|
|
47
|
+
const decipher = createDecipheriv("aes-256-gcm", keyOf(), buf.subarray(0, 12));
|
|
48
|
+
decipher.setAuthTag(buf.subarray(buf.length - 16));
|
|
49
|
+
try {
|
|
50
|
+
return Buffer.concat([decipher.update(buf.subarray(12, buf.length - 16)), decipher.final()]).toString("utf8");
|
|
51
|
+
} catch {
|
|
52
|
+
throw new DecryptionError();
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
10
56
|
}
|
|
11
57
|
|
|
12
58
|
/** Built-in converters (no external dependencies: Node/Bun `crypto`). */
|
|
13
59
|
export const ValueConverters = {
|
|
14
60
|
/**
|
|
15
|
-
* AES-256-GCM at-rest string encryption
|
|
16
|
-
*
|
|
61
|
+
* AES-256-GCM at-rest string encryption with a key given in code, for the ORM
|
|
62
|
+
* without an application. In an application take the key from its
|
|
63
|
+
* configuration: `encryptedWith(cryptoConfig, "phoneKey")` from `bazis/core/orm`.
|
|
17
64
|
*/
|
|
18
65
|
encrypted(secret: string): ValueConverter<string, string> {
|
|
19
|
-
|
|
20
|
-
return {
|
|
21
|
-
toProvider(plaintext: string): string {
|
|
22
|
-
if (plaintext === "") {
|
|
23
|
-
return "";
|
|
24
|
-
}
|
|
25
|
-
const iv = randomBytes(12);
|
|
26
|
-
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
27
|
-
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
28
|
-
const tag = cipher.getAuthTag();
|
|
29
|
-
return Buffer.concat([iv, encrypted, tag]).toString("base64");
|
|
30
|
-
},
|
|
31
|
-
fromProvider(stored: string): string {
|
|
32
|
-
if (stored === "") {
|
|
33
|
-
return "";
|
|
34
|
-
}
|
|
35
|
-
const buf = Buffer.from(stored, "base64");
|
|
36
|
-
const iv = buf.subarray(0, 12);
|
|
37
|
-
const tag = buf.subarray(buf.length - 16);
|
|
38
|
-
const ciphertext = buf.subarray(12, buf.length - 16);
|
|
39
|
-
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
40
|
-
decipher.setAuthTag(tag);
|
|
41
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
42
|
-
},
|
|
43
|
-
};
|
|
66
|
+
return aesGcmConverter(() => secret);
|
|
44
67
|
},
|
|
45
68
|
|
|
46
69
|
/** JSON <-> string through serialization (for custom structures on top of a text column). */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OrmError } from "../errors";
|
|
1
2
|
import type { PropertyModel } from "../Metadata/types";
|
|
2
3
|
import type { SqlDialect, SqlParam } from "./types";
|
|
3
4
|
|
|
@@ -7,8 +8,26 @@ export function encodeProperty(property: PropertyModel, value: unknown, dialect:
|
|
|
7
8
|
return dialect.encode(modelValue, property.type);
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
/** Decodes a column value: dialect -> converter. */
|
|
11
|
-
export function decodeProperty(property: PropertyModel, value: unknown, dialect: SqlDialect): unknown {
|
|
11
|
+
/** Decodes a column value: dialect -> converter. A converter failure names the entity and the property. */
|
|
12
|
+
export function decodeProperty(property: PropertyModel, value: unknown, dialect: SqlDialect, owner?: string): unknown {
|
|
12
13
|
const providerValue = dialect.decode(value, property.type);
|
|
13
|
-
|
|
14
|
+
if (!property.converter) return providerValue;
|
|
15
|
+
try {
|
|
16
|
+
return property.converter.fromProvider(providerValue);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
19
|
+
throw new OrmError(`Cannot read ${owner ?? "entity"}.${property.propertyName}: ${reason}`, { cause: error });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A condition or an ordering on a column whose converter stores equal values
|
|
25
|
+
* differently (encryption with a random nonce) cannot work in SQL.
|
|
26
|
+
*/
|
|
27
|
+
export function assertComparable(property: PropertyModel, owner: string): void {
|
|
28
|
+
if (property.converter?.comparable !== false) return;
|
|
29
|
+
throw new OrmError(
|
|
30
|
+
`${owner}.${property.propertyName} is encrypted with a random nonce: it cannot be compared or sorted in SQL. ` +
|
|
31
|
+
`To find rows by it, store a separate column with a keyed hash of the value (HMAC) and compare that.`,
|
|
32
|
+
);
|
|
14
33
|
}
|
|
@@ -2,7 +2,7 @@ import { OrmError } from "../errors";
|
|
|
2
2
|
import { renderPostgresOnConflictDoNothing, renderPostgresSkipLocked } from "../Providers/PostgresDialect";
|
|
3
3
|
import type { EntityModel, PropertyModel } from "../Metadata/types";
|
|
4
4
|
import type { SqlDialect, SqlParam } from "../Providers/types";
|
|
5
|
-
import { encodeProperty } from "../Providers/propertyConversion";
|
|
5
|
+
import { assertComparable, encodeProperty } from "../Providers/propertyConversion";
|
|
6
6
|
import type { Condition } from "./conditions";
|
|
7
7
|
import type { QueryPlan } from "./QueryPlan";
|
|
8
8
|
|
|
@@ -121,7 +121,9 @@ export class SqlTranslator {
|
|
|
121
121
|
return "";
|
|
122
122
|
}
|
|
123
123
|
const parts = plan.orders.map((order) => {
|
|
124
|
-
const
|
|
124
|
+
const property = this.requireProperty(order.property);
|
|
125
|
+
assertComparable(property, this.model.name);
|
|
126
|
+
const column = property.columnName;
|
|
125
127
|
return `${this.dialect.quoteId(column)}${order.descending ? " DESC" : " ASC"}`;
|
|
126
128
|
});
|
|
127
129
|
return ` ORDER BY ${parts.join(", ")}`;
|
|
@@ -144,6 +146,7 @@ export class SqlTranslator {
|
|
|
144
146
|
switch (condition.kind) {
|
|
145
147
|
case "compare": {
|
|
146
148
|
const property = this.requireProperty(condition.property);
|
|
149
|
+
assertComparable(property, this.model.name);
|
|
147
150
|
const placeholder = this.dialect.parameter(params.length);
|
|
148
151
|
params.push(encode(property, condition.value));
|
|
149
152
|
// startsWith/endsWith/contains escape wildcards with `\`; this works
|
|
@@ -153,6 +156,7 @@ export class SqlTranslator {
|
|
|
153
156
|
}
|
|
154
157
|
case "in": {
|
|
155
158
|
const property = this.requireProperty(condition.property);
|
|
159
|
+
assertComparable(property, this.model.name);
|
|
156
160
|
if (condition.values.length === 0) {
|
|
157
161
|
return "0 = 1"; // an empty IN is always false
|
|
158
162
|
}
|
|
@@ -167,7 +171,7 @@ export class SqlTranslator {
|
|
|
167
171
|
if (condition.properties.length === 0 || condition.values.length === 0) return "0 = 1";
|
|
168
172
|
return `(${condition.values.map((tuple) => {
|
|
169
173
|
if (tuple.length !== condition.properties.length) throw new OrmError("Tuple predicate component count mismatch.");
|
|
170
|
-
return `(${tuple.map((value, index) => { const property = this.requireProperty(condition.properties[index]!); const placeholder = this.dialect.parameter(params.length); params.push(encode(property, value)); return `${this.dialect.quoteId(property.columnName)} = ${placeholder}`; }).join(" AND ")})`;
|
|
174
|
+
return `(${tuple.map((value, index) => { const property = this.requireProperty(condition.properties[index]!); assertComparable(property, this.model.name); const placeholder = this.dialect.parameter(params.length); params.push(encode(property, value)); return `${this.dialect.quoteId(property.columnName)} = ${placeholder}`; }).join(" AND ")})`;
|
|
171
175
|
}).join(" OR ")})`;
|
|
172
176
|
}
|
|
173
177
|
case "null": {
|
|
@@ -11,7 +11,7 @@ export function materialize<T extends object>(model: EntityModel, row: Row, dial
|
|
|
11
11
|
const entity = new model.ctor() as Record<string, unknown>;
|
|
12
12
|
for (const property of model.properties) {
|
|
13
13
|
if (property.columnName in row) {
|
|
14
|
-
entity[property.propertyName] = decodeProperty(property, row[property.columnName], dialect);
|
|
14
|
+
entity[property.propertyName] = decodeProperty(property, row[property.columnName], dialect, model.name);
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
return entity as T;
|
|
@@ -32,7 +32,7 @@ export function materializeProjection(
|
|
|
32
32
|
}
|
|
33
33
|
const column = alias in row ? alias : property.columnName;
|
|
34
34
|
if (column in row) {
|
|
35
|
-
result[alias] = decodeProperty(property, row[column], dialect);
|
|
35
|
+
result[alias] = decodeProperty(property, row[column], dialect, model.name);
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
return result;
|
package/library/orm/index.ts
CHANGED
|
@@ -45,7 +45,7 @@ export {
|
|
|
45
45
|
type ScalarLogicalFieldType,
|
|
46
46
|
} from "./Metadata/DynamicModelBuilder";
|
|
47
47
|
export { pluralize } from "./Metadata/conventions";
|
|
48
|
-
export { ValueConverters, type ValueConverter } from "./Metadata/ValueConverter";
|
|
48
|
+
export { DecryptionError, ValueConverters, type ValueConverter } from "./Metadata/ValueConverter";
|
|
49
49
|
export type {
|
|
50
50
|
ColumnOptionsType,
|
|
51
51
|
ColumnType,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.104.0",
|
|
4
4
|
"description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexander Alenin",
|