@coffre/db 0.1.0 → 0.1.2

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/log.d.ts CHANGED
@@ -68,5 +68,11 @@ export declare function lockLogHead(tx: Transaction): Promise<{
68
68
  nextSeq: bigint;
69
69
  headHash: Buffer;
70
70
  }>;
71
- export declare function appendEntries(tx: Transaction, key: LogKey, entries: readonly NewEntry[]): Promise<Appended>;
71
+ /**
72
+ * Append `entries` under `key`. `check`, when given, runs once the head is
73
+ * locked and before anything is written: a writer's own condition on the
74
+ * log as it stands, which no other append can change until this one
75
+ * commits. It throws to refuse.
76
+ */
77
+ export declare function appendEntries(tx: Transaction, key: LogKey, entries: readonly NewEntry[], check?: (tx: Transaction) => Promise<void>): Promise<Appended>;
72
78
  //#endregion
package/dist/log.js CHANGED
@@ -60,10 +60,17 @@ async function lockLogHead(tx) {
60
60
  if (head === void 0) throw new Error("audit_chain_head is missing; refusing to write an unchained entry");
61
61
  return head;
62
62
  }
63
- async function appendEntries(tx, key, entries) {
63
+ /**
64
+ * Append `entries` under `key`. `check`, when given, runs once the head is
65
+ * locked and before anything is written: a writer's own condition on the
66
+ * log as it stands, which no other append can change until this one
67
+ * commits. It throws to refuse.
68
+ */
69
+ async function appendEntries(tx, key, entries, check) {
64
70
  if (entries.length === 0) throw new Error("appendEntries called with no entries");
65
71
  const { auditChainHead, auditLog } = tablesOf(tx);
66
72
  const head = await lockLogHead(tx);
73
+ await check?.(tx);
67
74
  const [{ now }] = await tx.select({ now: clockMillis(tx) }).from(auditChainHead);
68
75
  const [last] = await tx.select({
69
76
  seq: auditLog.seq,
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.2",
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.2"
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/log.ts CHANGED
@@ -111,12 +111,24 @@ export async function lockLogHead(tx: Transaction): Promise<{ nextSeq: bigint; h
111
111
  return head;
112
112
  }
113
113
 
114
- export async function appendEntries(tx: Transaction, key: LogKey, entries: readonly NewEntry[]): Promise<Appended> {
114
+ /**
115
+ * Append `entries` under `key`. `check`, when given, runs once the head is
116
+ * locked and before anything is written: a writer's own condition on the
117
+ * log as it stands, which no other append can change until this one
118
+ * commits. It throws to refuse.
119
+ */
120
+ export async function appendEntries(
121
+ tx: Transaction,
122
+ key: LogKey,
123
+ entries: readonly NewEntry[],
124
+ check?: (tx: Transaction) => Promise<void>,
125
+ ): Promise<Appended> {
115
126
  if (entries.length === 0) throw new Error('appendEntries called with no entries');
116
127
  const { auditChainHead, auditLog } = tablesOf(tx);
117
128
 
118
129
  // Every append queues here, if its transaction has not already.
119
130
  const head = await lockLogHead(tx);
131
+ await check?.(tx);
120
132
  const [{ now }] = await tx.select({ now: clockMillis(tx) }).from(auditChainHead);
121
133
  const [last] = await tx
122
134
  .select({ seq: auditLog.seq, hash: auditLog.hash })
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