@coffre/db 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,13 @@ connections, Hyperdrive's included.
8
8
  A deployment does not import it. `@coffre/server` runs its migrations with
9
9
  `coffre-server migrate`. The server and vault open the same database with
10
10
  separate logins, `coffre_runtime` and `coffre_vault_runtime`. The migrations
11
- grant each its own rights; only the owner runs migrations.
11
+ grant each its own rights; only the owner runs migrations. The owner needs
12
+ `CREATEROLE` and `CREATEDB`, and `ADMIN OPTION` on existing group roles.
13
+ Superuser is not required.
14
+
15
+ Node connections accept `sslrootcert=system` with `sslmode=verify-full`
16
+ and use Node's default trusted CAs. They check both the certificate chain
17
+ and the database hostname.
12
18
 
13
19
  Part of [coffre](https://github.com/erwinkn/coffre), a secrets manager you
14
20
  deploy as a small project of your own. Its eight `@coffre/*` packages are
package/dist/connect.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { t as Database } from "./database-CWpo5aF0.js";
2
2
  import { Engine } from "./dialect.js";
3
+ import { ClientConfig } from "pg";
4
+ //#region src/postgres.d.ts
5
+ /** Keep libpq's system-root shortcut from being read as a certificate filename by pg. */
6
+ export declare function postgresConnection(url: string): ClientConfig;
7
+ //#endregion
3
8
  //#region src/connect.d.ts
4
9
  /**
5
10
  * Open a database from its URL, on a Node server:
package/dist/connect.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createDatabase } from "./index.js";
2
2
  import { asPostgresDatabase } from "./portable.js";
3
+ import { t as postgresConnection } from "./postgres-Bmar8y0q.js";
3
4
  import pg from "pg";
4
5
  import { resolve } from "node:path";
5
6
  //#region src/connect.ts
@@ -16,7 +17,7 @@ import { resolve } from "node:path";
16
17
  async function openDatabase(url) {
17
18
  switch (engineOfUrl(url)) {
18
19
  case "postgres": {
19
- const pool = new pg.Pool({ connectionString: url });
20
+ const pool = new pg.Pool(postgresConnection(url));
20
21
  return {
21
22
  engine: "postgres",
22
23
  db: createDatabase(pool),
@@ -152,4 +153,4 @@ function numbers(result) {
152
153
  return result;
153
154
  }
154
155
  //#endregion
155
- export { engineOfUrl, openDatabase };
156
+ export { engineOfUrl, openDatabase, postgresConnection };
@@ -1,3 +1,4 @@
1
+ import { t as postgresConnection } from "./postgres-Bmar8y0q.js";
1
2
  import pg from "pg";
2
3
  //#region src/hyperdrive.ts
3
4
  /** Postgres through Hyperdrive, the one database a Worker runs on, the app's and the vault's. */
@@ -44,7 +45,7 @@ var HyperdrivePool = class {
44
45
  };
45
46
  }
46
47
  async #connectClient() {
47
- const client = new pg.Client({ connectionString: this.#connectionString });
48
+ const client = new pg.Client(postgresConnection(this.#connectionString));
48
49
  await client.connect();
49
50
  return client;
50
51
  }
package/dist/migrate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { t as postgresConnection } from "./postgres-Bmar8y0q.js";
1
2
  import { engineOfUrl } from "./connect.js";
2
3
  import { createHash } from "node:crypto";
3
4
  import { readFile } from "node:fs/promises";
@@ -53,8 +54,8 @@ async function postgresMigrator(url) {
53
54
  import("drizzle-orm/node-postgres/migrator")
54
55
  ]);
55
56
  const pool = new pg.Pool({
57
+ ...postgresConnection(url),
56
58
  application_name: "coffre-migrations",
57
- connectionString: url,
58
59
  max: 1
59
60
  });
60
61
  const client = await pool.connect();
@@ -308,19 +308,22 @@ DECLARE
308
308
  BEGIN
309
309
  FOREACH name IN ARRAY ARRAY['coffre_app', 'coffre_vault'] LOOP
310
310
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = name) THEN
311
- EXECUTE format('CREATE ROLE %I', name);
311
+ EXECUTE format('CREATE ROLE %I NOLOGIN INHERIT NOCREATEDB NOCREATEROLE', name);
312
+ ELSE
313
+ -- Managed owners cannot ALTER the superuser-only attributes, even to turn them off.
314
+ IF EXISTS (
315
+ SELECT 1 FROM pg_roles WHERE rolname = name
316
+ AND (rolsuper OR rolreplication OR rolbypassrls)
317
+ ) THEN
318
+ RAISE EXCEPTION '% has unsafe role attributes: SUPERUSER, REPLICATION and BYPASSRLS must be off', name;
319
+ END IF;
320
+ EXECUTE format('ALTER ROLE %I NOLOGIN INHERIT NOCREATEDB NOCREATEROLE', name);
312
321
  END IF;
313
322
  END LOOP;
314
323
  END
315
324
  $$;
316
325
  --> statement-breakpoint
317
326
 
318
- ALTER ROLE coffre_app
319
- NOLOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
320
- --> statement-breakpoint
321
- ALTER ROLE coffre_vault
322
- NOLOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
323
- --> statement-breakpoint
324
327
 
325
328
  DO $$
326
329
  DECLARE
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "b2cb2e5d-c648-458d-97bb-34bb07156047",
2
+ "id": "b76d9374-5d86-42d1-9d06-1af17adadd6b",
3
3
  "prevId": "00000000-0000-0000-0000-000000000000",
4
4
  "version": "7",
5
5
  "dialect": "postgresql",
@@ -5,7 +5,7 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "7",
8
- "when": 1790874461895,
8
+ "when": 1790930587507,
9
9
  "tag": "0000_baseline",
10
10
  "breakpoints": true
11
11
  }
@@ -0,0 +1,29 @@
1
+ import { checkServerIdentity } from "node:tls";
2
+ import { parseIntoClientConfig } from "pg-connection-string";
3
+ //#region src/postgres.ts
4
+ /** Keep libpq's system-root shortcut from being read as a certificate filename by pg. */
5
+ function postgresConnection(url) {
6
+ let parsed;
7
+ try {
8
+ parsed = new URL(url);
9
+ } catch {
10
+ throw new Error("invalid Postgres URL");
11
+ }
12
+ const parameters = parsed.searchParams;
13
+ if (parameters.getAll("sslrootcert").at(-1) !== "system") return { connectionString: url };
14
+ const mode = parameters.getAll("sslmode").at(-1);
15
+ if (mode !== void 0 && mode !== "verify-full") throw new Error("sslrootcert=system requires sslmode=verify-full");
16
+ parameters.delete("sslrootcert");
17
+ parameters.set("sslmode", "verify-full");
18
+ const config = parseIntoClientConfig(parsed.toString());
19
+ const host = config.host;
20
+ if (!host) throw new Error("sslrootcert=system requires a Postgres hostname");
21
+ config.ssl = {
22
+ ...config.ssl,
23
+ rejectUnauthorized: true,
24
+ checkServerIdentity: (_hostname, certificate) => checkServerIdentity(host, certificate)
25
+ };
26
+ return config;
27
+ }
28
+ //#endregion
29
+ export { postgresConnection as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/db",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "coffre's database: the schema, its migrations and the connections, on Postgres or SQLite.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -73,7 +73,8 @@
73
73
  "@libsql/client": "0.18.0",
74
74
  "drizzle-orm": "0.45.2",
75
75
  "pg": "8.16.3",
76
- "@coffre/core": "0.1.0"
76
+ "pg-connection-string": "2.14.0",
77
+ "@coffre/core": "0.1.1"
77
78
  },
78
79
  "devDependencies": {
79
80
  "@types/node": "26.1.1",
@@ -44,19 +44,22 @@ DECLARE
44
44
  BEGIN
45
45
  FOREACH name IN ARRAY ARRAY['coffre_app', 'coffre_vault'] LOOP
46
46
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = name) THEN
47
- EXECUTE format('CREATE ROLE %I', name);
47
+ EXECUTE format('CREATE ROLE %I NOLOGIN INHERIT NOCREATEDB NOCREATEROLE', name);
48
+ ELSE
49
+ -- Managed owners cannot ALTER the superuser-only attributes, even to turn them off.
50
+ IF EXISTS (
51
+ SELECT 1 FROM pg_roles WHERE rolname = name
52
+ AND (rolsuper OR rolreplication OR rolbypassrls)
53
+ ) THEN
54
+ RAISE EXCEPTION '% has unsafe role attributes: SUPERUSER, REPLICATION and BYPASSRLS must be off', name;
55
+ END IF;
56
+ EXECUTE format('ALTER ROLE %I NOLOGIN INHERIT NOCREATEDB NOCREATEROLE', name);
48
57
  END IF;
49
58
  END LOOP;
50
59
  END
51
60
  $$;
52
61
  --> statement-breakpoint
53
62
 
54
- ALTER ROLE coffre_app
55
- NOLOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
56
- --> statement-breakpoint
57
- ALTER ROLE coffre_vault
58
- NOLOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
59
- --> statement-breakpoint
60
63
 
61
64
  DO $$
62
65
  DECLARE
package/src/connect.ts CHANGED
@@ -6,6 +6,9 @@ import pg from 'pg';
6
6
  import { createDatabase, type Database } from './database.ts';
7
7
  import type { Engine } from './dialect.ts';
8
8
  import { asPostgresDatabase } from './portable.ts';
9
+ import { postgresConnection } from './postgres.ts';
10
+
11
+ export { postgresConnection } from './postgres.ts';
9
12
 
10
13
  /**
11
14
  * Open a database from its URL, on a Node server:
@@ -20,7 +23,7 @@ import { asPostgresDatabase } from './portable.ts';
20
23
  export async function openDatabase(url: string): Promise<OpenDatabase> {
21
24
  switch (engineOfUrl(url)) {
22
25
  case 'postgres': {
23
- const pool = new pg.Pool({ connectionString: url });
26
+ const pool = new pg.Pool(postgresConnection(url));
24
27
  return { engine: 'postgres', db: createDatabase(pool), close: () => pool.end() };
25
28
  }
26
29
  case 'sqlite': {
package/src/hyperdrive.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import pg from 'pg';
2
2
 
3
3
  import type { PoolLike } from './database.ts';
4
+ import { postgresConnection } from './postgres.ts';
4
5
 
5
6
  /** Postgres through a Hyperdrive binding: `postgres(env.HYPERDRIVE)`. */
6
7
  export type PostgresDatabase = { readonly engine: 'postgres'; readonly hyperdrive: { readonly connectionString: string } };
@@ -55,7 +56,7 @@ export class HyperdrivePool implements Exclude<PoolLike, pg.Pool> {
55
56
  }
56
57
 
57
58
  async #connectClient(): Promise<pg.Client> {
58
- const client = new pg.Client({ connectionString: this.#connectionString });
59
+ const client = new pg.Client(postgresConnection(this.#connectionString));
59
60
  await client.connect();
60
61
  return client;
61
62
  }
package/src/migrate.ts CHANGED
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
 
5
5
  import { engineOfUrl } from './connect.ts';
6
6
  import type { Engine } from './dialect.ts';
7
+ import { postgresConnection } from './postgres.ts';
7
8
 
8
9
  /**
9
10
  * Bring a database up to date with its engine's migration tree:
@@ -118,7 +119,7 @@ async function postgresMigrator(url: string): Promise<Migrator> {
118
119
  import('drizzle-orm/node-postgres'),
119
120
  import('drizzle-orm/node-postgres/migrator'),
120
121
  ]);
121
- const pool = new pg.Pool({ application_name: 'coffre-migrations', connectionString: url, max: 1 });
122
+ const pool = new pg.Pool({ ...postgresConnection(url), application_name: 'coffre-migrations', max: 1 });
122
123
  const client = await pool.connect();
123
124
  return {
124
125
  async applied() {
@@ -308,19 +308,22 @@ DECLARE
308
308
  BEGIN
309
309
  FOREACH name IN ARRAY ARRAY['coffre_app', 'coffre_vault'] LOOP
310
310
  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = name) THEN
311
- EXECUTE format('CREATE ROLE %I', name);
311
+ EXECUTE format('CREATE ROLE %I NOLOGIN INHERIT NOCREATEDB NOCREATEROLE', name);
312
+ ELSE
313
+ -- Managed owners cannot ALTER the superuser-only attributes, even to turn them off.
314
+ IF EXISTS (
315
+ SELECT 1 FROM pg_roles WHERE rolname = name
316
+ AND (rolsuper OR rolreplication OR rolbypassrls)
317
+ ) THEN
318
+ RAISE EXCEPTION '% has unsafe role attributes: SUPERUSER, REPLICATION and BYPASSRLS must be off', name;
319
+ END IF;
320
+ EXECUTE format('ALTER ROLE %I NOLOGIN INHERIT NOCREATEDB NOCREATEROLE', name);
312
321
  END IF;
313
322
  END LOOP;
314
323
  END
315
324
  $$;
316
325
  --> statement-breakpoint
317
326
 
318
- ALTER ROLE coffre_app
319
- NOLOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
320
- --> statement-breakpoint
321
- ALTER ROLE coffre_vault
322
- NOLOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
323
- --> statement-breakpoint
324
327
 
325
328
  DO $$
326
329
  DECLARE
@@ -1,5 +1,5 @@
1
1
  {
2
- "id": "b2cb2e5d-c648-458d-97bb-34bb07156047",
2
+ "id": "b76d9374-5d86-42d1-9d06-1af17adadd6b",
3
3
  "prevId": "00000000-0000-0000-0000-000000000000",
4
4
  "version": "7",
5
5
  "dialect": "postgresql",
@@ -5,7 +5,7 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "7",
8
- "when": 1790874461895,
8
+ "when": 1790930587507,
9
9
  "tag": "0000_baseline",
10
10
  "breakpoints": true
11
11
  }
@@ -0,0 +1,35 @@
1
+ import { checkServerIdentity } from 'node:tls';
2
+
3
+ import { parseIntoClientConfig } from 'pg-connection-string';
4
+ import type { ClientConfig } from 'pg';
5
+
6
+ /** Keep libpq's system-root shortcut from being read as a certificate filename by pg. */
7
+ export function postgresConnection(url: string): ClientConfig {
8
+ let parsed: URL;
9
+ try {
10
+ parsed = new URL(url);
11
+ } catch {
12
+ // A parse error must not print the URL's password.
13
+ throw new Error('invalid Postgres URL');
14
+ }
15
+ const parameters = parsed.searchParams;
16
+ if (parameters.getAll('sslrootcert').at(-1) !== 'system') return { connectionString: url };
17
+ const mode = parameters.getAll('sslmode').at(-1);
18
+ if (mode !== undefined && mode !== 'verify-full') {
19
+ throw new Error('sslrootcert=system requires sslmode=verify-full');
20
+ }
21
+ parameters.delete('sslrootcert');
22
+ parameters.set('sslmode', 'verify-full');
23
+ const config = parseIntoClientConfig(parsed.toString());
24
+ const host = config.host;
25
+ if (!host) throw new Error('sslrootcert=system requires a Postgres hostname');
26
+ // Do not pass a connectionString too: pg would replace these SSL options.
27
+ // With no CA override, Node uses its default trusted roots. pg omits SNI
28
+ // for IP addresses, so bind verification to the actual host explicitly.
29
+ config.ssl = {
30
+ ...config.ssl as object,
31
+ rejectUnauthorized: true,
32
+ checkServerIdentity: (_hostname, certificate) => checkServerIdentity(host, certificate),
33
+ };
34
+ return config;
35
+ }
@@ -1,6 +1,8 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import pg from 'pg';
3
3
 
4
+ import { postgresConnection } from './postgres.ts';
5
+
4
6
  function requiredEnvironment(name: string): string {
5
7
  const value = process.env[name]?.trim();
6
8
  if (!value) throw new Error(`${name} is required`);
@@ -10,7 +12,7 @@ function requiredEnvironment(name: string): string {
10
12
  const expectedRole = requiredEnvironment('COFFRE_RUNTIME_ROLE');
11
13
  const pool = new pg.Pool({
12
14
  application_name: 'coffre-runtime-verification',
13
- connectionString: requiredEnvironment('DATABASE_URL'),
15
+ ...postgresConnection(requiredEnvironment('DATABASE_URL')),
14
16
  max: 1,
15
17
  });
16
18