@decohere/data-typeorm 0.0.1-dev.126 → 0.0.1-dev.5

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.
Files changed (75) hide show
  1. package/package.json +8 -24
  2. package/src/bootstrap-policy.ts +84 -0
  3. package/src/bootstrap.ts +76 -0
  4. package/src/data-source.factory.ts +51 -0
  5. package/src/database-options.ts +10 -0
  6. package/src/dialect-map.ts +12 -0
  7. package/{es6/src/inject-data-source.js → src/inject-data-source.ts} +15 -9
  8. package/src/scripts/user.ts +234 -0
  9. package/src/server-manager.ts +300 -0
  10. package/src/sql-pretty.transport.ts +29 -0
  11. package/src/type-orm.module.ts +155 -0
  12. package/src/type-orm.provider.ts +37 -0
  13. package/src/type-orm.repository.ts +33 -0
  14. package/src/typeorm-scribe.logger.ts +55 -0
  15. package/src/typeorm.extensions.ts +42 -0
  16. package/src/unit-of-work.ts +52 -0
  17. package/tests/bootstrap-schemas.docker.spec.ts +145 -0
  18. package/tests/bootstrap.spec.ts +156 -0
  19. package/tests/db-least-privilege.pentest.spec.ts +125 -0
  20. package/tests/postgres-container.ts +139 -0
  21. package/tests/prepare-database.docker.spec.ts +96 -0
  22. package/tests/server-manager.bootstrap.spec.ts +72 -0
  23. package/tests/trusted-dev-postgres.spec.ts +212 -0
  24. package/tests/type-orm.module.spec.ts +123 -0
  25. package/tests/typeorm.lifecycle.spec.ts +97 -0
  26. package/tests/unit-of-work.spec.ts +54 -0
  27. package/tests/user-scripts.pentest.spec.ts +42 -0
  28. package/LICENSE +0 -15
  29. package/commonjs/src/bootstrap-policy.js +0 -69
  30. package/commonjs/src/bootstrap.js +0 -142
  31. package/commonjs/src/data-source.factory.js +0 -42
  32. package/commonjs/src/database-options.js +0 -4
  33. package/commonjs/src/dialect-map.js +0 -26
  34. package/commonjs/src/index.js +0 -24
  35. package/commonjs/src/inject-data-source.js +0 -33
  36. package/commonjs/src/scripts/user.js +0 -259
  37. package/commonjs/src/server-manager.js +0 -294
  38. package/commonjs/src/sql-pretty.transport.js +0 -35
  39. package/commonjs/src/type-orm.module.js +0 -115
  40. package/commonjs/src/type-orm.provider.js +0 -36
  41. package/commonjs/src/type-orm.repository.js +0 -30
  42. package/commonjs/src/typeorm-scribe.logger.js +0 -70
  43. package/commonjs/src/typeorm.extensions.js +0 -60
  44. package/commonjs/src/unit-of-work.js +0 -49
  45. package/es6/src/bootstrap-policy.js +0 -55
  46. package/es6/src/bootstrap.js +0 -139
  47. package/es6/src/data-source.factory.js +0 -27
  48. package/es6/src/database-options.js +0 -1
  49. package/es6/src/dialect-map.js +0 -8
  50. package/es6/src/scripts/user.js +0 -209
  51. package/es6/src/server-manager.js +0 -284
  52. package/es6/src/sql-pretty.transport.js +0 -20
  53. package/es6/src/type-orm.module.js +0 -105
  54. package/es6/src/type-orm.provider.js +0 -26
  55. package/es6/src/type-orm.repository.js +0 -20
  56. package/es6/src/typeorm-scribe.logger.js +0 -60
  57. package/es6/src/typeorm.extensions.js +0 -50
  58. package/es6/src/unit-of-work.js +0 -39
  59. package/types/src/bootstrap-policy.d.ts +0 -19
  60. package/types/src/bootstrap.d.ts +0 -16
  61. package/types/src/data-source.factory.d.ts +0 -17
  62. package/types/src/database-options.d.ts +0 -9
  63. package/types/src/dialect-map.d.ts +0 -8
  64. package/types/src/index.d.ts +0 -7
  65. package/types/src/inject-data-source.d.ts +0 -14
  66. package/types/src/scripts/user.d.ts +0 -56
  67. package/types/src/server-manager.d.ts +0 -39
  68. package/types/src/sql-pretty.transport.d.ts +0 -2
  69. package/types/src/type-orm.module.d.ts +0 -17
  70. package/types/src/type-orm.provider.d.ts +0 -14
  71. package/types/src/type-orm.repository.d.ts +0 -12
  72. package/types/src/typeorm-scribe.logger.d.ts +0 -9
  73. package/types/src/typeorm.extensions.d.ts +0 -11
  74. package/types/src/unit-of-work.d.ts +0 -20
  75. /package/{es6/src/index.js → src/index.ts} +0 -0
package/package.json CHANGED
@@ -1,25 +1,9 @@
1
1
  {
2
2
  "name": "@decohere/data-typeorm",
3
- "version": "0.0.1-dev.126",
4
- "description": "TypeORM and Postgres adapter for @decohere/data: DataSource, migrations and least-privilege roles",
5
- "keywords": [
6
- "typescript",
7
- "typeorm",
8
- "postgres",
9
- "data-access",
10
- "decohere"
11
- ],
12
- "homepage": "https://github.com/typesmiths/decohere/tree/main/projects/data/data-typeorm#readme",
13
- "bugs": {
14
- "url": "https://github.com/typesmiths/decohere/issues"
15
- },
16
- "repository": {
17
- "type": "git",
18
- "url": "git+https://github.com/typesmiths/decohere.git",
19
- "directory": "projects/data/data-typeorm"
20
- },
3
+ "version": "0.0.1-dev.5",
4
+ "description": "",
21
5
  "license": "ISC",
22
- "author": "Typesmiths",
6
+ "author": "",
23
7
  "type": "module",
24
8
  "main": "index.js",
25
9
  "exports": {
@@ -33,11 +17,11 @@
33
17
  "test": "echo \"Error: no test specified\" && exit 1"
34
18
  },
35
19
  "dependencies": {
36
- "@decohere/core": "0.0.1-dev.126",
37
- "@decohere/crucible": "0.0.1-dev.126",
38
- "@decohere/data": "0.0.1-dev.126",
39
- "@decohere/http": "0.0.1-dev.126",
40
- "@decohere/scribe": "0.0.1-dev.126",
20
+ "@decohere/data": "0.0.1-dev.5",
21
+ "@decohere/http": "0.0.1-dev.5",
22
+ "@decohere/di": "0.0.1-dev.5",
23
+ "@decohere/core": "0.0.1-dev.5",
24
+ "@decohere/scribe": "0.0.1-dev.5",
41
25
  "pg": "^8.16.3",
42
26
  "pino-pretty": "^13.1.3",
43
27
  "typeorm": "^0.3.31"
@@ -0,0 +1,84 @@
1
+ import { type ConnectionInfo, DataConfigurationError } from "@decohere/data";
2
+
3
+ /** True when NODE_ENV says development. Only then may a data source declare `auth: trust`. */
4
+ export function isDevelopmentEnv(nodeEnv: string | undefined = process.env.NODE_ENV): boolean {
5
+ const env = nodeEnv?.toLowerCase() ?? "";
6
+ return env === "development" || env === "dev";
7
+ }
8
+
9
+ function isTrusted(info: ConnectionInfo): boolean {
10
+ return info.auth === "trust";
11
+ }
12
+
13
+ function has(value: string | undefined): boolean {
14
+ return typeof value === "string" && value.length > 0;
15
+ }
16
+
17
+ /**
18
+ * Check how a data source signs in, in every environment. A server that needs no password is
19
+ * declared with `auth: trust`, never inferred from a missing password: a password left out by
20
+ * mistake would otherwise look like a trusted server and fail later, less clearly.
21
+ */
22
+ export function assertDataSourceAuth(
23
+ info: ConnectionInfo,
24
+ nodeEnv: string | undefined = process.env.NODE_ENV,
25
+ ): void {
26
+ const auth = info.auth ?? "password";
27
+ if (auth !== "password" && auth !== "trust") {
28
+ throw new DataConfigurationError(
29
+ `Data source "${info.name}": auth must be "password" or "trust" (got ${JSON.stringify(auth)})`,
30
+ );
31
+ }
32
+ if (auth === "trust") {
33
+ if (!isDevelopmentEnv(nodeEnv)) {
34
+ throw new DataConfigurationError(
35
+ `Data source "${info.name}" sets auth: trust, which is allowed only in development (NODE_ENV is ${JSON.stringify(nodeEnv ?? "")}). A server that trusts every connection belongs on a developer's machine.`,
36
+ );
37
+ }
38
+ return;
39
+ }
40
+ if (!has(info.password)) {
41
+ throw new DataConfigurationError(
42
+ `Data source "${info.name}" has no password. Set password, or auth: trust for a local server that trusts every connection.`,
43
+ );
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Decide whether to bootstrap a data source (create its database, roles and schema).
49
+ *
50
+ * - `bootstrap: false` → never
51
+ * - `bootstrap: true` → yes (requires adminUser, and adminPassword unless `auth: trust`)
52
+ * - omitted → only in development, when those admin credentials are present
53
+ */
54
+ export function shouldBootstrapDatabase(
55
+ info: ConnectionInfo,
56
+ nodeEnv: string | undefined = process.env.NODE_ENV,
57
+ ): boolean {
58
+ if (info.bootstrap === false) {
59
+ return false;
60
+ }
61
+
62
+ const hasAdmin = has(info.adminUser) && (isTrusted(info) || has(info.adminPassword));
63
+
64
+ if (info.bootstrap === true) {
65
+ if (!hasAdmin) {
66
+ throw new DataConfigurationError(
67
+ `Data source "${info.name}": bootstrap requires adminUser and adminPassword (adminPassword may be left out only with auth: trust)`,
68
+ );
69
+ }
70
+ return true;
71
+ }
72
+
73
+ return isDevelopmentEnv(nodeEnv) && hasAdmin;
74
+ }
75
+
76
+ /** A data source that will be bootstrapped must have every password the bootstrap needs. */
77
+ export function assertBootstrapCredentials(info: ConnectionInfo): void {
78
+ if (isTrusted(info)) return;
79
+ if (has(info.migrateUsername) && !has(info.migratePassword)) {
80
+ throw new DataConfigurationError(
81
+ `Data source "${info.name}" has migrateUsername but no migratePassword. Set migratePassword, or auth: trust for a local server that trusts every connection.`,
82
+ );
83
+ }
84
+ }
@@ -0,0 +1,76 @@
1
+ import { type ConnectionInfo, DataConfigurationError } from "@decohere/data";
2
+ import {
3
+ assertBootstrapCredentials,
4
+ assertDataSourceAuth,
5
+ shouldBootstrapDatabase,
6
+ } from "./bootstrap-policy.js";
7
+ import { PostgresServerManager } from "./server-manager.js";
8
+
9
+ export type BootstrapDataSourcesOptions = {
10
+ /** Shown as `application_name` on the bootstrap connections. */
11
+ applicationName: string;
12
+ /** Defaults to `process.env.NODE_ENV`. Decides whether `auth: trust` is allowed. */
13
+ nodeEnv?: string;
14
+ };
15
+
16
+ /** What every data source on one server and database must agree on. */
17
+ const SHARED_FIELDS = ["auth", "adminUser", "migrateUsername"] as const;
18
+
19
+ /**
20
+ * Check every data source's auth, then create the databases, roles and schemas of those that
21
+ * bootstrap. Data sources on the same server and database are bootstrapped once, one after the
22
+ * other: in parallel they would race to create the same database and roles. Running it again
23
+ * changes nothing.
24
+ *
25
+ * For runners and scripts that open their own `DataSource`; `withTypeOrm` calls it for you.
26
+ */
27
+ export async function bootstrapDataSources(
28
+ dataSources: readonly ConnectionInfo[],
29
+ options: BootstrapDataSourcesOptions,
30
+ ): Promise<void> {
31
+ const nodeEnv = options.nodeEnv ?? process.env.NODE_ENV;
32
+ for (const info of dataSources) assertDataSourceAuth(info, nodeEnv);
33
+
34
+ const toBootstrap = dataSources.filter((info) => shouldBootstrapDatabase(info, nodeEnv));
35
+ for (const info of toBootstrap) assertBootstrapCredentials(info);
36
+
37
+ for (const group of byServerAndDatabase(toBootstrap)) {
38
+ const [first] = group;
39
+ await using server = new PostgresServerManager(options.applicationName, first);
40
+ await server.connect();
41
+ await server.initializeDatabase(first.database);
42
+ for (const info of group) {
43
+ await server.provisionRoles({
44
+ database: info.database,
45
+ appUsername: info.username,
46
+ appPassword: info.password,
47
+ migrateUsername: info.migrateUsername,
48
+ migratePassword: info.migratePassword,
49
+ allowAppDdl: info.synchronize === true,
50
+ schema: info.schema,
51
+ });
52
+ }
53
+ }
54
+ }
55
+
56
+ function byServerAndDatabase(dataSources: readonly ConnectionInfo[]): ConnectionInfo[][] {
57
+ const groups = new Map<string, ConnectionInfo[]>();
58
+ for (const info of dataSources) {
59
+ const key = `${info.host}:${info.port}/${info.database}`;
60
+ const group = groups.get(key);
61
+ if (group == null) {
62
+ groups.set(key, [info]);
63
+ continue;
64
+ }
65
+ const [first] = group;
66
+ for (const field of SHARED_FIELDS) {
67
+ if (info[field] !== first[field]) {
68
+ throw new DataConfigurationError(
69
+ `Data sources "${first.name}" and "${info.name}" share database ${key} but set different ${field} (${JSON.stringify(first[field])} and ${JSON.stringify(info[field])})`,
70
+ );
71
+ }
72
+ }
73
+ group.push(info);
74
+ }
75
+ return [...groups.values()];
76
+ }
@@ -0,0 +1,51 @@
1
+ import type { ConnectionInfo } from "@decohere/data";
2
+ import type { Constructable } from "@decohere/core/reflection";
3
+ import { DataSource, type Logger } from "typeorm";
4
+ import { ProviderMap } from "./dialect-map.js";
5
+
6
+ export type ConnectionOptions = ConnectionInfo & {
7
+ entities: Constructable[];
8
+ applicationName?: string;
9
+ };
10
+
11
+ /** Fields used only for bootstrap / privilege provisioning — not TypeORM connection options. */
12
+ function omitProvisionFields(options: ConnectionOptions) {
13
+ const {
14
+ adminUser: _adminUser,
15
+ adminPassword: _adminPassword,
16
+ migrateUsername: _migrateUsername,
17
+ migratePassword: _migratePassword,
18
+ bootstrap: _bootstrap,
19
+ auth: _auth,
20
+ seed: _seed,
21
+ dialect: _dialect,
22
+ name: _name,
23
+ ...rest
24
+ } = options;
25
+ return rest;
26
+ }
27
+
28
+ /**
29
+ * A DataSource the container can close: TypeORM's has `destroy()` but no `Symbol.asyncDispose`,
30
+ * so disposing the app's container used to leave its connections open (#541).
31
+ */
32
+ export class ManagedDataSource extends DataSource {
33
+ public async [Symbol.asyncDispose]() {
34
+ if (this.isInitialized) await this.destroy();
35
+ }
36
+ }
37
+
38
+ export class DataSourceFactory {
39
+ public static create(options: ConnectionOptions, logger: Logger): DataSource {
40
+ const { applicationName } = options;
41
+ const { provider } = options;
42
+ const type = ProviderMap[provider];
43
+
44
+ return new ManagedDataSource({
45
+ type,
46
+ logger,
47
+ applicationName,
48
+ ...omitProvisionFields(options),
49
+ });
50
+ }
51
+ }
@@ -0,0 +1,10 @@
1
+ import type { Constructable } from "@decohere/core/reflection";
2
+ import type { DataSource } from "typeorm";
3
+
4
+ export type ModuleConfig = {
5
+ /** The unique identifier for the data source. Required when multiple data sources are configured. */
6
+ name?: string | symbol;
7
+ repositories?: Constructable[];
8
+ entities: Constructable[];
9
+ seed?: (dataSource: DataSource) => Promise<void>;
10
+ };
@@ -0,0 +1,12 @@
1
+ import type { Dialect, Provider } from "@decohere/data";
2
+ import type { DatabaseType } from "typeorm";
3
+
4
+ export const ProviderMap = {
5
+ postgres: "postgres",
6
+ mssql: "mssql",
7
+ } satisfies Record<Provider, DatabaseType>;
8
+
9
+ export const DialectMap = {
10
+ postgresql: "postgres",
11
+ tsql: "mssql",
12
+ } satisfies Record<Dialect, DatabaseType>;
@@ -1,20 +1,26 @@
1
1
  import { DEFAULT_DATA_SOURCE } from "@decohere/data";
2
- import { inject } from "@decohere/crucible/decorators";
3
- /** Default TypeORM DataSource when `withTypeOrm` is used without `name`. */ export const TypeOrmDataSourceToken = Symbol("TypeOrmDataSource");
2
+ import { inject } from "@decohere/di/decorators";
3
+
4
+ /** Default TypeORM DataSource when `withTypeOrm` is used without `name`. */
5
+ export const TypeOrmDataSourceToken = Symbol("TypeOrmDataSource");
6
+
4
7
  /**
5
8
  * Container key for a TypeORM DataSource. Distinct from the unit-of-work key
6
9
  * (datasource name / {@link DEFAULT_DATA_SOURCE}) so request-scoped UoW and
7
10
  * process-lifetime DataSource can coexist.
8
- */ export function typeOrmDataSourceToken(name) {
9
- if (name == null || name === DEFAULT_DATA_SOURCE) {
10
- return TypeOrmDataSourceToken;
11
- }
12
- return Symbol.for(`decohere:typeorm:datasource:${String(name)}`);
11
+ */
12
+ export function typeOrmDataSourceToken(name?: string | symbol): symbol {
13
+ if (name == null || name === DEFAULT_DATA_SOURCE) {
14
+ return TypeOrmDataSourceToken;
15
+ }
16
+ return Symbol.for(`decohere:typeorm:datasource:${String(name)}`);
13
17
  }
18
+
14
19
  /**
15
20
  * Inject the TypeORM DataSource for process-lifetime (singleton) work.
16
21
  * Pass the same `name` as `withTypeOrm({ name })`. Request-scoped persistence
17
22
  * should keep using `injectUnitOfWork`.
18
- */ export function injectDataSource(name) {
19
- return inject(typeOrmDataSourceToken(name));
23
+ */
24
+ export function injectDataSource(name?: string | symbol) {
25
+ return inject(typeOrmDataSourceToken(name));
20
26
  }
@@ -0,0 +1,234 @@
1
+ /** Postgres identifier: letters, digits, underscore; must start with letter or underscore. */
2
+ const SAFE_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
+
4
+ export function assertSafeSqlIdent(value: string, label: string): string {
5
+ if (typeof value !== "string" || value.length === 0 || !SAFE_IDENT.test(value)) {
6
+ throw new Error(
7
+ `Invalid ${label}: must match ${SAFE_IDENT} (got ${JSON.stringify(value)})`,
8
+ );
9
+ }
10
+ return value;
11
+ }
12
+
13
+ /** Double-quote a validated identifier for use in DDL. */
14
+ export function quoteIdent(value: string, label = "identifier"): string {
15
+ const safe = assertSafeSqlIdent(value, label);
16
+ return `"${safe.replaceAll('"', '""')}"`;
17
+ }
18
+
19
+ /**
20
+ * Single-quote a string literal for DDL that cannot take bind parameters
21
+ * (e.g. CREATE USER … PASSWORD — Postgres rejects PASSWORD $1).
22
+ */
23
+ export function quoteLiteral(value: string): string {
24
+ return `'${value.replaceAll("'", "''")}'`;
25
+ }
26
+
27
+ export type SqlQuery = { text: string; values: unknown[] };
28
+
29
+ /** Existence check — username bound as $1 (never concatenated). */
30
+ export function roleExistsQuery(username: string): SqlQuery {
31
+ assertSafeSqlIdent(username, "username");
32
+ return {
33
+ text: `SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = $1`,
34
+ values: [username],
35
+ };
36
+ }
37
+
38
+ /**
39
+ * CREATE USER with password as an escaped SQL literal.
40
+ * Username is validated + quoted as an identifier (cannot be parameterized in DDL).
41
+ * Password cannot use bind params in CREATE USER … PASSWORD (Postgres syntax error).
42
+ *
43
+ * `password` omitted (undefined) creates a role with no password, for a local dev server that
44
+ * trusts every connection. An empty string is still an error: that is a typo, not a choice.
45
+ * Callers decide when no password is allowed (see PostgresServerManager).
46
+ */
47
+ export function createUserQuery(username: string, password?: string): SqlQuery {
48
+ const userIdent = quoteIdent(username, "username");
49
+ const attributes = "NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE";
50
+ if (password === undefined) {
51
+ return { text: `CREATE USER ${userIdent} WITH LOGIN ${attributes}`, values: [] };
52
+ }
53
+ if (typeof password !== "string" || password.length === 0) {
54
+ throw new Error("Invalid password: must be a non-empty string");
55
+ }
56
+ const passwordLiteral = quoteLiteral(password);
57
+ return {
58
+ text: `CREATE USER ${userIdent} WITH LOGIN PASSWORD ${passwordLiteral} ${attributes}`,
59
+ values: [],
60
+ };
61
+ }
62
+
63
+ /** CONNECT only — production runtime app role. */
64
+ export function grantConnectQuery(username: string, database: string): SqlQuery {
65
+ const userIdent = quoteIdent(username, "username");
66
+ const dbIdent = quoteIdent(database, "database");
67
+ return {
68
+ text: `GRANT CONNECT ON DATABASE ${dbIdent} TO ${userIdent}`,
69
+ values: [],
70
+ };
71
+ }
72
+
73
+ export function grantSchemaUsageQuery(username: string, schema = "public"): SqlQuery {
74
+ const userIdent = quoteIdent(username, "username");
75
+ const schemaIdent = quoteIdent(schema, "schema");
76
+ return {
77
+ text: `GRANT USAGE ON SCHEMA ${schemaIdent} TO ${userIdent}`,
78
+ values: [],
79
+ };
80
+ }
81
+
82
+ /** Needed for TypeORM synchronize (dev only). */
83
+ export function grantSchemaCreateQuery(username: string, schema = "public"): SqlQuery {
84
+ const userIdent = quoteIdent(username, "username");
85
+ const schemaIdent = quoteIdent(schema, "schema");
86
+ return {
87
+ text: `GRANT CREATE ON SCHEMA ${schemaIdent} TO ${userIdent}`,
88
+ values: [],
89
+ };
90
+ }
91
+
92
+ /** The schema, owned by `owner`. Unchanged when it already exists. */
93
+ export function createSchemaQuery(schema: string, owner: string): SqlQuery {
94
+ const schemaIdent = quoteIdent(schema, "schema");
95
+ const ownerIdent = quoteIdent(owner, "owner");
96
+ return {
97
+ text: `CREATE SCHEMA IF NOT EXISTS ${schemaIdent} AUTHORIZATION ${ownerIdent}`,
98
+ values: [],
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Whether a role already holds a privilege. Postgres checks that the grantor may grant even when
104
+ * the grant would change nothing, so bootstrap asks first and grants only what is missing.
105
+ */
106
+ export function hasDatabasePrivilegeQuery(
107
+ username: string,
108
+ database: string,
109
+ privilege: string,
110
+ ): SqlQuery {
111
+ assertSafeSqlIdent(username, "username");
112
+ assertSafeSqlIdent(database, "database");
113
+ return {
114
+ text: `SELECT has_database_privilege($1, $2, $3) AS granted`,
115
+ values: [username, database, privilege],
116
+ };
117
+ }
118
+
119
+ export function hasSchemaPrivilegeQuery(
120
+ username: string,
121
+ schema: string,
122
+ privilege: string,
123
+ ): SqlQuery {
124
+ assertSafeSqlIdent(username, "username");
125
+ assertSafeSqlIdent(schema, "schema");
126
+ return {
127
+ text: `SELECT has_schema_privilege($1, $2, $3) AS granted`,
128
+ values: [username, schema, privilege],
129
+ };
130
+ }
131
+
132
+ /** Whether `member` already has the privileges of `role` (inherited, or as a superuser). */
133
+ export function hasRolePrivilegesQuery(member: string, role: string): SqlQuery {
134
+ assertSafeSqlIdent(member, "member");
135
+ assertSafeSqlIdent(role, "role");
136
+ return {
137
+ text: `SELECT pg_has_role($1, $2, 'USAGE') AS granted`,
138
+ values: [member, role],
139
+ };
140
+ }
141
+
142
+ export function databaseOwnerQuery(database: string): SqlQuery {
143
+ assertSafeSqlIdent(database, "database");
144
+ return {
145
+ text: `SELECT pg_get_userbyid(datdba) AS owner FROM pg_catalog.pg_database WHERE datname = $1`,
146
+ values: [database],
147
+ };
148
+ }
149
+
150
+ export function grantSchemaPrivilegesQuery(username: string, schema = "public"): SqlQuery {
151
+ const userIdent = quoteIdent(username, "username");
152
+ const schemaIdent = quoteIdent(schema, "schema");
153
+ return {
154
+ text: `GRANT ALL ON SCHEMA ${schemaIdent} TO ${userIdent}`,
155
+ values: [],
156
+ };
157
+ }
158
+
159
+ export function alterDatabaseOwnerQuery(database: string, username: string): SqlQuery {
160
+ const dbIdent = quoteIdent(database, "database");
161
+ const userIdent = quoteIdent(username, "username");
162
+ return {
163
+ text: `ALTER DATABASE ${dbIdent} OWNER TO ${userIdent}`,
164
+ values: [],
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Postgres requires the session user to be a member of the target role to
170
+ * `ALTER DATABASE … OWNER TO` / `ALTER DEFAULT PRIVILEGES FOR ROLE`.
171
+ */
172
+ export function grantRoleMembershipQuery(member: string, role: string): SqlQuery {
173
+ const memberIdent = quoteIdent(member, "member");
174
+ const roleIdent = quoteIdent(role, "role");
175
+ return {
176
+ text: `GRANT ${roleIdent} TO ${memberIdent}`,
177
+ values: [],
178
+ };
179
+ }
180
+
181
+ export function revokeRoleMembershipQuery(member: string, role: string): SqlQuery {
182
+ const memberIdent = quoteIdent(member, "member");
183
+ const roleIdent = quoteIdent(role, "role");
184
+ return {
185
+ text: `REVOKE ${roleIdent} FROM ${memberIdent}`,
186
+ values: [],
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Future tables/sequences created by the migration role are usable by the app role (DML only).
192
+ */
193
+ export function defaultPrivilegesForAppQueries(
194
+ migrateUsername: string,
195
+ appUsername: string,
196
+ schema = "public",
197
+ ): SqlQuery[] {
198
+ const migrateIdent = quoteIdent(migrateUsername, "migrateUsername");
199
+ const appIdent = quoteIdent(appUsername, "username");
200
+ const schemaIdent = quoteIdent(schema, "schema");
201
+ return [
202
+ {
203
+ text: `ALTER DEFAULT PRIVILEGES FOR ROLE ${migrateIdent} IN SCHEMA ${schemaIdent} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${appIdent}`,
204
+ values: [],
205
+ },
206
+ {
207
+ text: `ALTER DEFAULT PRIVILEGES FOR ROLE ${migrateIdent} IN SCHEMA ${schemaIdent} GRANT USAGE, SELECT ON SEQUENCES TO ${appIdent}`,
208
+ values: [],
209
+ },
210
+ ];
211
+ }
212
+
213
+ /**
214
+ * @deprecated Prefer discrete query helpers + PostgresServerManager.provisionRoles.
215
+ * Kept for call-site / pen-test migration: emits create + CONNECT + USAGE (not ALL PRIVILEGES).
216
+ */
217
+ const createOwnerUser = (username: string, password: string, database: string): SqlQuery => {
218
+ quoteIdent(username, "username");
219
+ quoteIdent(database, "database");
220
+ if (typeof password !== "string" || password.length === 0) {
221
+ throw new Error("Invalid password: must be a non-empty string");
222
+ }
223
+ const create = createUserQuery(username, password);
224
+ const connect = grantConnectQuery(username, database);
225
+ const usage = grantSchemaUsageQuery(username);
226
+ return {
227
+ text: [create.text, connect.text, usage.text].join(";\n"),
228
+ values: [],
229
+ };
230
+ };
231
+
232
+ const userExists = (username: string): SqlQuery => roleExistsQuery(username);
233
+
234
+ export const userScripts = { createOwnerUser, userExists };