@coffre/cli 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.
@@ -0,0 +1,521 @@
1
+ import { d as esm_default } from "./esm-Bts-esAl.js";
2
+ import { n as generateKeys, t as KEYS_EXPLAINED } from "./keys-D9CG_wYV.js";
3
+ import { r as postgresConnection, t as engineOfUrl } from "./connect-BONR52al.js";
4
+ import { parseArgs } from "node:util";
5
+ import { readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { createHash, createHmac, pbkdf2Sync, randomBytes } from "node:crypto";
8
+ import { createInterface } from "node:readline/promises";
9
+ import { Writable } from "node:stream";
10
+ import { readFile } from "node:fs/promises";
11
+ //#region ../db/dist/migrate.js
12
+ /**
13
+ * Bring a database up to date with its engine's migration tree:
14
+ * migrations/postgres or migrations/sqlite. Both trees
15
+ * describe one schema; see schema-parity.test.ts.
16
+ *
17
+ * Applied history must be a prefix of the local journal, byte for byte,
18
+ * before and after. Only one migrator runs at a time: Postgres takes an
19
+ * advisory lock, and SQLite's own write lock covers the
20
+ * whole run. Restricted runtime roles are Postgres-only (baseline/postgres.sql).
21
+ */
22
+ const MIGRATION_LOCK_KEY = "7165058122361679213";
23
+ /**
24
+ * Beside this module: src/migrations in the workspace, and dist/migrations
25
+ * in the built package, where the build copies them.
26
+ */
27
+ function migrationsFolder(engine) {
28
+ return fileURLToPath(new URL(`./migrations/${engine}`, import.meta.url));
29
+ }
30
+ async function expectedMigrations(engine) {
31
+ const folder = migrationsFolder(engine);
32
+ const journal = JSON.parse(await readFile(`${folder}/meta/_journal.json`, "utf8"));
33
+ if (!Array.isArray(journal.entries)) throw new Error("invalid Drizzle migration journal");
34
+ const expected = await Promise.all(journal.entries.map(async (entry, position) => {
35
+ if (entry.idx !== position || !Number.isSafeInteger(entry.when) || typeof entry.tag !== "string" || !/^[a-zA-Z0-9_-]+$/.test(entry.tag)) throw new Error(`invalid Drizzle migration journal entry at index ${position}`);
36
+ const sqlText = await readFile(`${folder}/${entry.tag}.sql`, "utf8");
37
+ return {
38
+ createdAt: String(entry.when),
39
+ hash: createHash("sha256").update(sqlText).digest("hex"),
40
+ tag: entry.tag
41
+ };
42
+ }));
43
+ for (let index = 1; index < expected.length; index += 1) if (BigInt(expected[index - 1].createdAt) >= BigInt(expected[index].createdAt)) throw new Error("Drizzle migration journal timestamps must be strictly increasing");
44
+ return expected;
45
+ }
46
+ function verifyHistory(expected, applied, requireComplete) {
47
+ const rows = applied ?? [];
48
+ if (rows.length > expected.length) throw new Error("database contains migrations that are absent from this image");
49
+ if (requireComplete && rows.length !== expected.length) throw new Error(`migration history is incomplete: expected ${expected.length}, found ${rows.length}`);
50
+ rows.forEach((row, index) => {
51
+ const local = expected[index];
52
+ if (row.created_at !== local.createdAt || row.hash !== local.hash) throw new Error(`migration history diverged at ${local.tag}; applied migrations must never be edited`);
53
+ });
54
+ }
55
+ async function postgresMigrator(url) {
56
+ const [{ default: pg }, { drizzle }, { migrate }] = await Promise.all([
57
+ import("./esm-B69mMLcR.js"),
58
+ import("./node-postgres-BlNvYfq_.js"),
59
+ import("./migrator-W1a-ooWi.js")
60
+ ]);
61
+ const pool = new pg.Pool({
62
+ ...postgresConnection(url),
63
+ application_name: "coffre-migrations",
64
+ max: 1
65
+ });
66
+ const client = await pool.connect();
67
+ return {
68
+ async applied() {
69
+ if (!(await client.query("SELECT to_regclass('drizzle.__drizzle_migrations')::text AS relation")).rows[0]?.relation) return null;
70
+ return (await client.query("SELECT hash, created_at::text FROM drizzle.__drizzle_migrations ORDER BY created_at, id")).rows;
71
+ },
72
+ async lock() {
73
+ await client.query("SET lock_timeout = '5min'");
74
+ await client.query("SELECT pg_advisory_lock($1::bigint)", [MIGRATION_LOCK_KEY]);
75
+ },
76
+ async unlock() {
77
+ await client.query("SELECT pg_advisory_unlock($1::bigint)", [MIGRATION_LOCK_KEY]);
78
+ },
79
+ migrate: (migrationsFolder) => migrate(drizzle(client), { migrationsFolder }),
80
+ async restrict() {
81
+ await client.query(`DO $$ BEGIN
82
+ EXECUTE format(
83
+ 'REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime',
84
+ current_database()
85
+ );
86
+ END $$`);
87
+ },
88
+ async close() {
89
+ client.release();
90
+ await pool.end();
91
+ }
92
+ };
93
+ }
94
+ async function sqliteMigrator(url) {
95
+ const [{ openDatabase }, { migrate }] = await Promise.all([import("./connect-BZdPYNbZ.js"), import("drizzle-orm/libsql/migrator")]);
96
+ const { db, close } = await openDatabase(url);
97
+ const sqlite = db;
98
+ return {
99
+ async applied() {
100
+ if ((await sqlite.all("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'")).length === 0) return null;
101
+ return sqlite.all("SELECT hash, CAST(created_at AS TEXT) AS created_at FROM __drizzle_migrations ORDER BY created_at, id");
102
+ },
103
+ async lock() {},
104
+ async unlock() {},
105
+ migrate: (migrationsFolder) => migrate(sqlite, { migrationsFolder }),
106
+ async restrict() {},
107
+ close
108
+ };
109
+ }
110
+ /** Apply missing migrations, check their history and reassert database privileges. */
111
+ async function migrateDatabase(url) {
112
+ const engine = engineOfUrl(url);
113
+ const expected = await expectedMigrations(engine);
114
+ const migrator = await {
115
+ postgres: postgresMigrator,
116
+ sqlite: sqliteMigrator
117
+ }[engine](url);
118
+ let locked = false;
119
+ try {
120
+ await migrator.lock();
121
+ locked = true;
122
+ verifyHistory(expected, await migrator.applied(), false);
123
+ await migrator.migrate(migrationsFolder(engine));
124
+ verifyHistory(expected, await migrator.applied(), true);
125
+ await migrator.restrict();
126
+ } finally {
127
+ try {
128
+ if (locked) await migrator.unlock();
129
+ } finally {
130
+ await migrator.close();
131
+ }
132
+ }
133
+ }
134
+ //#endregion
135
+ //#region src/setup.ts
136
+ /** Where the administrator's connection string comes from, when not from a hidden prompt. */
137
+ const URL_VARIABLE = "COFFRE_SETUP_DATABASE_URL";
138
+ /** The two runtime roles, as the migration names them, and the Hyperdrive config each gets on Workers. */
139
+ const ROLES = {
140
+ app: "coffre_runtime",
141
+ vault: "coffre_vault_runtime"
142
+ };
143
+ const HYPERDRIVE = {
144
+ app: "coffre",
145
+ vault: "coffre-vault"
146
+ };
147
+ var SetupError = class extends Error {};
148
+ /**
149
+ * The name a role logs in as. On PlanetScale Postgres, a login names its
150
+ * branch: the administrator connects as `postgres.<branch id>`, and every
151
+ * role on that branch as `<role>.<branch id>`. Elsewhere, the role's name.
152
+ */
153
+ function loginFor(role, administrator) {
154
+ const dot = administrator.indexOf(".");
155
+ return dot === -1 ? role : `${role}${administrator.slice(dot)}`;
156
+ }
157
+ /** The administrator's URL, as `login` with `password`: same host, database and TLS settings. */
158
+ function loginUrl(administrator, login, password) {
159
+ const url = new URL(administrator.href);
160
+ url.username = encodeURIComponent(login);
161
+ url.password = password;
162
+ return url.href;
163
+ }
164
+ /**
165
+ * A login's URL for Hyperdrive, without its parameters: Hyperdrive always
166
+ * connects over TLS, checking the certificate against public CAs, and takes
167
+ * no `sslrootcert`.
168
+ */
169
+ function hyperdriveUrl(url) {
170
+ const bare = new URL(url);
171
+ bare.search = "";
172
+ bare.hash = "";
173
+ return bare.href;
174
+ }
175
+ /**
176
+ * What Postgres keeps for a password under SCRAM-SHA-256 (RFC 5802 and
177
+ * 7677), made here, so that the password itself never reaches the server,
178
+ * nor its logs.
179
+ */
180
+ function scramVerifier(password, salt = randomBytes(16), iterations = 4096) {
181
+ const salted = pbkdf2Sync(password, salt, iterations, 32, "sha256");
182
+ const stored = createHash("sha256").update(createHmac("sha256", salted).update("Client Key").digest()).digest();
183
+ const server = createHmac("sha256", salted).update("Server Key").digest();
184
+ return `SCRAM-SHA-256$${iterations}:${salt.toString("base64")}$${stored.toString("base64")}:${server.toString("base64")}`;
185
+ }
186
+ /** 24 random bytes, URL-safe: nothing to encode in a connection string. */
187
+ function newPassword() {
188
+ return randomBytes(24).toString("base64url");
189
+ }
190
+ async function setup(args) {
191
+ const secrets = [];
192
+ try {
193
+ const options = parseOptions(args);
194
+ const { text, interactive } = await readAdministrator();
195
+ secrets.push(text);
196
+ const administrator = administratorUrl(text);
197
+ if (administrator.password !== "") secrets.push(administrator.password, decodeURIComponent(administrator.password));
198
+ const result = await run(administrator, {
199
+ ...options,
200
+ interactive
201
+ }, secrets);
202
+ process.stdout.write(options.json ? `${JSON.stringify(asJson(result))}\n` : formatSetup(result));
203
+ } catch (error) {
204
+ process.stderr.write(`coffre setup: ${redact(error instanceof Error ? error.message : String(error), secrets)}\n`);
205
+ process.exit(1);
206
+ }
207
+ }
208
+ function parseOptions(args) {
209
+ try {
210
+ const { values } = parseArgs({
211
+ args,
212
+ options: {
213
+ "reset-passwords": {
214
+ type: "boolean",
215
+ default: false
216
+ },
217
+ json: {
218
+ type: "boolean",
219
+ default: false
220
+ }
221
+ },
222
+ allowPositionals: false,
223
+ strict: true
224
+ });
225
+ return {
226
+ resetPasswords: values["reset-passwords"],
227
+ json: values.json
228
+ };
229
+ } catch {
230
+ const leaked = args.some((arg) => /postgres(ql)?:|@/i.test(arg));
231
+ throw new SetupError(`it takes only --reset-passwords and --json. It reads the administrator's connection string from a hidden prompt, ${URL_VARIABLE} or stdin, never from the command line, where the shell's history and other users can read it.` + (leaked ? " One of the arguments looks like one: change that password, which is in your shell history now." : ""));
232
+ }
233
+ }
234
+ /** The connection string: from the environment, from stdin when it is not a terminal, or typed at a hidden prompt. */
235
+ async function readAdministrator() {
236
+ const terminal = process.stdin.isTTY === true;
237
+ const given = process.env[URL_VARIABLE];
238
+ delete process.env[URL_VARIABLE];
239
+ if (given !== void 0 && given.trim() !== "") return {
240
+ text: given.trim(),
241
+ interactive: terminal
242
+ };
243
+ if (!terminal) {
244
+ const line = readFileSync(0, "utf8").split("\n").find((candidate) => candidate.trim() !== "");
245
+ if (line === void 0) throw new SetupError(`no connection string: pipe it to stdin, or set ${URL_VARIABLE}`);
246
+ return {
247
+ text: line.trim(),
248
+ interactive: false
249
+ };
250
+ }
251
+ const text = await hidden("The database administrator's connection string (hidden): ");
252
+ if (text === "") throw new SetupError("no connection string given");
253
+ return {
254
+ text,
255
+ interactive: true
256
+ };
257
+ }
258
+ function administratorUrl(text) {
259
+ let url;
260
+ try {
261
+ url = new URL(text);
262
+ } catch {
263
+ throw new SetupError("that is not a connection string, such as postgresql://user:password@host:5432/database");
264
+ }
265
+ if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") throw new SetupError("coffre setup needs a Postgres connection string, postgresql://…");
266
+ if (url.username === "" || url.hostname === "" || url.pathname.length <= 1) throw new SetupError("the connection string must name a user, a host and a database");
267
+ return url;
268
+ }
269
+ async function run(administrator, options, secrets) {
270
+ const user = decodeURIComponent(administrator.username);
271
+ const logins = await provision(administrator, user, options, secrets);
272
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
273
+ note(`migrating the database to coffre ${version}'s schema, as ${user}`);
274
+ await migrateDatabase(administrator.href);
275
+ note("checking the boundary, as each login");
276
+ return {
277
+ keys: !await withClient(administrator.href, async (client) => {
278
+ for (const component of ["app", "vault"]) {
279
+ const login = logins[component];
280
+ check(component, login, login.url === null ? await granted(client, login.role) : await probed(login.url));
281
+ }
282
+ return (await client.query("SELECT EXISTS (SELECT 1 FROM audit_log) AS used")).rows[0].used;
283
+ }) && (logins.app.url !== null || logins.vault.url !== null) ? generateKeys() : null,
284
+ ...logins
285
+ };
286
+ }
287
+ /**
288
+ * Each runtime login, created with a fresh password, or, if it exists, its
289
+ * password set again when asked to; otherwise left as it is.
290
+ */
291
+ async function provision(administrator, user, options, secrets) {
292
+ return withClient(administrator.href, async (client) => {
293
+ const [self] = (await client.query("SELECT rolsuper, rolcreaterole FROM pg_roles WHERE rolname = current_user")).rows;
294
+ if (!self?.rolsuper && !self?.rolcreaterole) throw new SetupError(`${user} cannot create roles: connect as the database's administrator, which has CREATEROLE`);
295
+ const existing = new Set((await client.query("SELECT rolname FROM pg_roles WHERE rolname = ANY($1)", [Object.values(ROLES)])).rows.map((row) => row.rolname));
296
+ let reset = options.resetPasswords;
297
+ if (existing.size > 0 && !reset) {
298
+ const [names, one] = [[...existing].join(" and "), existing.size === 1];
299
+ if (options.interactive) {
300
+ note(`${names} ${one ? "exists" : "exist"} already`);
301
+ reset = await confirm("Set new passwords? Each component then needs its new connection string [y/N] ");
302
+ } else note(`${names} ${one ? "exists" : "exist"} already, and ${one ? "keeps its password" : "keep their passwords"}: --reset-passwords sets new ones`);
303
+ }
304
+ const logins = {};
305
+ for (const component of ["app", "vault"]) {
306
+ const role = ROLES[component];
307
+ const login = loginFor(role, user);
308
+ const create = !existing.has(role);
309
+ if (!create && !reset) {
310
+ logins[component] = {
311
+ role,
312
+ login,
313
+ password: "kept",
314
+ url: null
315
+ };
316
+ continue;
317
+ }
318
+ const password = newPassword();
319
+ secrets.push(password);
320
+ const verifier = client.escapeLiteral(scramVerifier(password));
321
+ await client.query(create ? `CREATE ROLE ${client.escapeIdentifier(role)} LOGIN INHERIT NOCREATEDB NOCREATEROLE PASSWORD ${verifier}` : `ALTER ROLE ${client.escapeIdentifier(role)} LOGIN PASSWORD ${verifier}`);
322
+ note(`${create ? "created" : "set a new password for"} ${role}${login === role ? "" : `, which logs in as ${login}`}`);
323
+ logins[component] = {
324
+ role,
325
+ login,
326
+ password: create ? "created" : "reset",
327
+ url: loginUrl(administrator, login, password)
328
+ };
329
+ }
330
+ return logins;
331
+ });
332
+ }
333
+ /** What each login may do that matters, and what it must answer. */
334
+ const BOUNDARY = [
335
+ {
336
+ what: "write members",
337
+ expected: {
338
+ app: false,
339
+ vault: true
340
+ },
341
+ sql: `INSERT INTO vault_members (principal, status, owner, generation, created_at, created_by, status_changed_at, status_changed_by, access_seq, mac)
342
+ VALUES ('user:coffre-setup@probe.invalid', 'active', false, 0, 0, 'system:coffre-setup', 0, 'system:coffre-setup', 0, decode(repeat('00', 32), 'hex'))`,
343
+ privilege: "has_table_privilege($1, 'public.vault_members', 'INSERT')"
344
+ },
345
+ {
346
+ what: "delete log entries",
347
+ expected: {
348
+ app: false,
349
+ vault: false
350
+ },
351
+ sql: "DELETE FROM audit_log",
352
+ privilege: "has_table_privilege($1, 'public.audit_log', 'DELETE')"
353
+ },
354
+ {
355
+ what: "create tables",
356
+ expected: {
357
+ app: false,
358
+ vault: false
359
+ },
360
+ sql: "CREATE TABLE coffre_setup_probe (probe integer)",
361
+ privilege: "has_schema_privilege($1, 'public', 'CREATE') OR has_database_privilege($1, current_database(), 'CREATE')"
362
+ }
363
+ ];
364
+ /** Each probe, tried as the login in a transaction rolled back: allowed, or refused for want of a privilege. */
365
+ async function probed(url) {
366
+ return withClient(url, async (client) => {
367
+ const allowed = [];
368
+ for (const { sql } of BOUNDARY) {
369
+ await client.query("BEGIN");
370
+ try {
371
+ await client.query(sql);
372
+ allowed.push(true);
373
+ } catch (error) {
374
+ if (error.code !== "42501") throw error;
375
+ allowed.push(false);
376
+ } finally {
377
+ await client.query("ROLLBACK");
378
+ }
379
+ }
380
+ return allowed;
381
+ });
382
+ }
383
+ /** The same, from the catalog, for a login whose password this run did not set: the administrator asks for it. */
384
+ async function granted(client, role) {
385
+ const allowed = [];
386
+ for (const { privilege } of BOUNDARY) allowed.push((await client.query(`SELECT ${privilege} AS allowed`, [role])).rows[0].allowed);
387
+ return allowed;
388
+ }
389
+ function check(component, login, allowed) {
390
+ const wrong = BOUNDARY.flatMap(({ what, expected }, i) => allowed[i] === expected[component] ? [] : [`${allowed[i] ? "can" : "cannot"} ${what}`]);
391
+ if (wrong.length > 0) throw new SetupError(`${login.role} ${wrong.join(", and ")}: the database's privileges are not what coffre needs. Nothing is shown: fix them, then run coffre setup again with --reset-passwords.`);
392
+ const can = BOUNDARY.filter(({ expected }) => expected[component]).map(({ what }) => what);
393
+ const cannot = BOUNDARY.filter(({ expected }) => !expected[component]).map(({ what }) => what);
394
+ const how = login.url === null ? ", by the catalog: its password is unchanged" : "";
395
+ note(` ${login.role.padEnd(20)} ${can.length > 0 ? `can ${listed(can, "and")}; ` : ""}cannot ${listed(cannot, "or")}${how}`);
396
+ }
397
+ /**
398
+ * Every value, once, as a dotenv block for each component, then, as
399
+ * comments, where each goes: Node's env files, or Workers' Hyperdrive
400
+ * configs, vars and secrets.
401
+ */
402
+ function formatSetup({ keys, app, vault }) {
403
+ const lines = [];
404
+ const say = (...text) => lines.push(...text);
405
+ if (keys === null && app.url === null && vault.url === null) return `# Nothing to save. The logins kept their passwords, and this database
406
+ # holds a deployment's data already, whose keys are the ones it was set up
407
+ # with. For new passwords: coffre setup --reset-passwords.
408
+ `;
409
+ say("# SAVE THESE NOW, in your password manager. They are shown once: coffre", "# keeps no copy, and the database holds only the passwords' verifiers.");
410
+ if (keys === null) say("#", "# No keys: this database holds a deployment's data already, whose keys", "# are the ones it was set up with.");
411
+ const url = (login) => login.url === null ? `# DATABASE_URL: ${login.role} kept its password; coffre setup --reset-passwords sets a new one.` : `DATABASE_URL=${login.url}`;
412
+ say("", "# The app: server.env on Node; Worker secrets and Hyperdrive on Workers.");
413
+ if (keys !== null) say(`AUDIT_CHAIN_KEY=${keys.AUDIT_CHAIN_KEY}`);
414
+ say(url(app), "", "# The vault: vault.env on Node; a var, a secret and Hyperdrive on Workers.");
415
+ if (keys !== null) say(`KEK_ID=${keys.KEK_ID}`, `KEK=${keys.KEK}`);
416
+ say(url(vault), "");
417
+ say("# On Node, the app's block goes in server.env and the vault's in vault.env,", "# beside the settings their .example files list, each readable only by", "# its process's user (chmod 600).");
418
+ const set = [app, vault].filter((login) => login.url !== null);
419
+ if (set.length > 0) {
420
+ say("#", "# On Workers, from the deployment's directory: each login reaches the", "# database through a Hyperdrive config of its own, with caching off, so", "# that a revoked session or grant stops at once. Hyperdrive always", "# connects over TLS, checking the certificate against public CAs, so", "# these URLs leave the parameters out:", "#");
421
+ for (const [component, login] of [["app", app], ["vault", vault]]) {
422
+ if (login.url === null) continue;
423
+ say(`# pnpm exec wrangler hyperdrive ${login.password === "created" ? `create ${HYPERDRIVE[component]} --caching-disabled` : `update <the ${component}'s config id>`} \\`, `# --connection-string='${hyperdriveUrl(login.url)}'`);
424
+ }
425
+ }
426
+ const places = [...set.some((login) => login.password === "created") ? ["the ids they print under hyperdrive, in app/wrangler.jsonc and vault/wrangler.jsonc"] : [], ...keys === null ? [] : ["KEK_ID under vars in vault/wrangler.jsonc"]];
427
+ if (places.length > 0) say("#", ...comment(`Put ${places.join(", and ")}.${keys === null ? "" : " Then set the two secrets, each pasted at its prompt:"}`));
428
+ if (keys !== null) say("#", "# pnpm exec wrangler secret put AUDIT_CHAIN_KEY -c app/wrangler.jsonc", "# pnpm exec wrangler secret put KEK -c vault/wrangler.jsonc", "#", KEYS_EXPLAINED.trimEnd());
429
+ say("#", "# The database is migrated, and each login holds only its rights. Run", "# `pnpm migrate` in the deployment after every upgrade of coffre.");
430
+ return `${lines.join("\n")}\n`;
431
+ }
432
+ /** `text` as comment lines of at most 76 columns. */
433
+ function comment(text) {
434
+ const lines = [];
435
+ let line = "#";
436
+ for (const word of text.split(" ")) {
437
+ if (line.length + 1 + word.length > 76) {
438
+ lines.push(line);
439
+ line = "#";
440
+ }
441
+ line += ` ${word}`;
442
+ }
443
+ return [...lines, line];
444
+ }
445
+ /** "a, b or c". */
446
+ function listed(items, last) {
447
+ return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} ${last} ${items.at(-1)}`;
448
+ }
449
+ /** The same values, for a script: each component's, and what happened to each login. */
450
+ function asJson({ keys, app, vault }) {
451
+ const database = (login) => login.url === null ? {} : { DATABASE_URL: login.url };
452
+ return {
453
+ app: {
454
+ ...keys === null ? {} : { AUDIT_CHAIN_KEY: keys.AUDIT_CHAIN_KEY },
455
+ ...database(app)
456
+ },
457
+ vault: {
458
+ ...keys === null ? {} : {
459
+ KEK_ID: keys.KEK_ID,
460
+ KEK: keys.KEK
461
+ },
462
+ ...database(vault)
463
+ },
464
+ logins: Object.fromEntries([app, vault].map(({ role, login, password }) => [role, {
465
+ login,
466
+ password
467
+ }]))
468
+ };
469
+ }
470
+ async function withClient(url, use) {
471
+ const client = new esm_default.Client({
472
+ ...postgresConnection(url),
473
+ application_name: "coffre-setup"
474
+ });
475
+ await client.connect();
476
+ try {
477
+ return await use(client);
478
+ } finally {
479
+ await client.end();
480
+ }
481
+ }
482
+ /** Progress, on stderr: stdout holds only what to save. */
483
+ function note(text) {
484
+ process.stderr.write(`${text}\n`);
485
+ }
486
+ /** An error's text without any secret in it: a driver may quote what it was given. */
487
+ function redact(text, secrets) {
488
+ return secrets.filter((secret) => secret.length >= 4).reduce((out, secret) => out.split(secret).join("…"), text);
489
+ }
490
+ async function hidden(question) {
491
+ const muted = new Writable({ write: (_chunk, _encoding, done) => done() });
492
+ const prompt = createInterface({
493
+ input: process.stdin,
494
+ output: muted,
495
+ terminal: true
496
+ });
497
+ prompt.on("SIGINT", () => {
498
+ process.stderr.write("\n");
499
+ process.exit(130);
500
+ });
501
+ process.stderr.write(question);
502
+ try {
503
+ return (await prompt.question("")).trim();
504
+ } finally {
505
+ prompt.close();
506
+ process.stderr.write("\n");
507
+ }
508
+ }
509
+ async function confirm(question) {
510
+ const prompt = createInterface({
511
+ input: process.stdin,
512
+ output: process.stderr
513
+ });
514
+ try {
515
+ return /^y(es)?$/i.test((await prompt.question(question)).trim());
516
+ } finally {
517
+ prompt.close();
518
+ }
519
+ }
520
+ //#endregion
521
+ export { setup };
@@ -19,48 +19,53 @@ from this directory, on Node 24 or later.
19
19
  pnpm install
20
20
  cp server.env.example server.env
21
21
  cp vault.env.example vault.env
22
+ chmod 600 server.env vault.env
22
23
  ```
23
24
 
24
- Fill both in: `PUBLIC_URL`, a GitHub OAuth app whose callback is
25
- `<PUBLIC_URL>/auth/callback/github`, `ROOT_ADMINS`, and three keys from
26
- `openssl rand -base64 32`. Escrow `KEK` and its `KEK_ID`, `SIGNING_KEY`, `AUDIT_CHAIN_KEY` and the OAuth
27
- client secret in a password manager. Without the KEK, stored values cannot
28
- be read; without the other keys, the existing log cannot be verified.
29
-
30
- ## 2. The database
31
-
32
- Use one Postgres database with three logins: its owner for migrations,
33
- `coffre_runtime` for the server, and `coffre_vault_runtime` for the vault.
34
- As an administrator, connect to the database with `psql` and create the
35
- runtime logins. `\password` prompts for each password without putting it
36
- in a SQL statement or shell history:
37
-
38
- ```sql
39
- CREATE ROLE coffre_runtime LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
40
- CREATE ROLE coffre_vault_runtime LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
41
- \password coffre_runtime
42
- \password coffre_vault_runtime
43
- ```
25
+ Fill in `PUBLIC_URL`, a GitHub OAuth app whose callback is
26
+ `<PUBLIC_URL>/auth/callback/github`, and `ROOT_ADMINS`. Keep each env file
27
+ readable only by its process's user.
28
+
29
+ ## 2. The database and keys
44
30
 
45
- Migrate as the owner, who must also be able to create and grant roles:
31
+ Make a Postgres database, then:
46
32
 
47
33
  ```sh
48
- pnpm migrate "postgres://owner:…@db.example.com:5432/coffre"
34
+ npx @coffre/cli setup
49
35
  ```
50
36
 
51
- The migration creates the `coffre_app` and `coffre_vault` group roles and
52
- grants each login only its group's rights. Only the vault writes members
53
- and grants; each login appends to the audit log only as itself. Run the
54
- migration again after every package upgrade, before starting either process.
55
-
56
- Fill `DATABASE_URL` in each env file with the same host and database, using
57
- `coffre_runtime` in `server.env` and `coffre_vault_runtime` in `vault.env`.
58
- URL-encode special characters in passwords. Neither process gets the owner's
59
- URL. Keep each env file readable only by its process's user (`chmod 600`).
37
+ Run it with the CLI you ran `coffre init` with. It asks for the database
38
+ administrator's connection string at a hidden prompt (a script can pipe it
39
+ in, or set `COFFRE_SETUP_DATABASE_URL`; never pass it as an argument). It
40
+ makes the two logins coffre runs as, `coffre_runtime` for the server and
41
+ `coffre_vault_runtime` for the vault, migrates the database, checks that
42
+ each login holds only its rights, and prints every value at once, as one
43
+ block for each process. It keeps no copy and writes no file. Save its
44
+ output in your password manager, with the OAuth client secret, before
45
+ anything else. Then the app's block goes in `server.env` and the vault's in
46
+ `vault.env`: one key for each process, so that the server, which faces the
47
+ network, never holds what decrypts a value.
48
+
49
+ - `KEK`, the vault's, decrypts every value, and the vault derives from it the
50
+ key it signs its records with. Lose it, and every value is lost.
51
+ - `AUDIT_CHAIN_KEY`, the server's, signs the server's log entries, sessions
52
+ and tokens. Lose it, and everyone is signed out and the log stops
53
+ verifying.
54
+ - Each `DATABASE_URL` is the same database through that process's own login.
55
+ Neither process gets the administrator's URL.
56
+
57
+ With AWS KMS instead of a key of your own, the vault also needs a
58
+ `SIGNING_KEY` ([keys](https://github.com/erwinkn/coffre/blob/main/docs/keys.md#aws-kms)).
59
+ To do the same by hand, see
60
+ [deploy.md](https://github.com/erwinkn/coffre/blob/main/docs/deploy.md#appendix-the-database-by-hand).
61
+
62
+ Run `pnpm migrate`, with the administrator's URL in `DATABASE_URL`, after
63
+ every package upgrade, before starting either process.
60
64
 
61
65
  For tests and local development only, both URLs may instead name the same
62
- absolute SQLite file, e.g. `file:/tmp/coffre-local.db`; migrate that URL once.
63
- SQLite has no database logins or separation of privileges.
66
+ absolute SQLite file, e.g. `file:/tmp/coffre-local.db`; migrate that URL once
67
+ with `pnpm migrate`, and make the keys with `npx @coffre/cli keys`. SQLite
68
+ has no database logins or separation of privileges.
64
69
 
65
70
  ## 3. Run
66
71
 
@@ -13,11 +13,11 @@
13
13
  "conformance": "coffre-conformance node"
14
14
  },
15
15
  "dependencies": {
16
- "@coffre/server": "0.1.0",
17
- "@coffre/vault": "0.1.0"
16
+ "@coffre/server": "0.1.2",
17
+ "@coffre/vault": "0.1.2"
18
18
  },
19
19
  "devDependencies": {
20
- "@coffre/conformance": "0.1.0",
20
+ "@coffre/conformance": "0.1.2",
21
21
  "@types/node": "26.1.1",
22
22
  "typescript": "5.9.3"
23
23
  }
@@ -4,7 +4,8 @@ PORT=3000
4
4
  # Where people reach coffre, through the proxy that terminates TLS in front
5
5
  # of it: an origin, no path.
6
6
  PUBLIC_URL=https://secrets.example.com
7
- # The vault uses this Postgres database through a different login.
7
+ # From `coffre setup`: the database, as the server's own login. The vault
8
+ # uses it through another.
8
9
  DATABASE_URL=postgres://coffre_runtime:CHANGE_ME@127.0.0.1:5432/coffre
9
10
  # The vault's socket, as in vault.env.
10
11
  VAULT_SOCKET=vault.sock
@@ -13,6 +14,6 @@ VAULT_SOCKET=vault.sock
13
14
  GITHUB_CLIENT_ID=
14
15
  GITHUB_CLIENT_SECRET=
15
16
 
16
- # 32 random bytes, base64 (`openssl rand -base64 32`): keys the audit log's
17
- # hash chain.
17
+ # From `coffre setup`, saved in your password manager first: signs the
18
+ # server's log entries, sessions and tokens.
18
19
  AUDIT_CHAIN_KEY=
@@ -16,7 +16,7 @@ const server = await serve({
16
16
  database: env('DATABASE_URL'),
17
17
  vault: connectVault(env('VAULT_SOCKET')),
18
18
  // For tests or local SQLite development only, the vault in this process:
19
- // vault: await localVault({ database: env('DATABASE_URL'), kek: …, rootAdmins: […], signingKey: … }),
19
+ // vault: await localVault({ database: env('DATABASE_URL'), kek: …, rootAdmins: […] }),
20
20
  auth: signin({
21
21
  providers: [
22
22
  github({
@@ -15,10 +15,12 @@ const vault = await serveVault({
15
15
  socket: env('VAULT_SOCKET'),
16
16
  database: env('DATABASE_URL'),
17
17
  kek: { id: env('KEK_ID'), key: env('KEK') },
18
- // After a rotation, the KEKs before it, so the data keys they wrapped
19
- // still open: previousKeks: [{ id: 'kek-1', key: env('KEK_1') }],
18
+ // After a rotation, the KEKs before it, so the data keys they wrapped still
19
+ // open and the records signed under them before it still verify:
20
+ // previousKeks: [{ id: 'kek-1', key: env('KEK_1') }],
20
21
  rootAdmins: env('ROOT_ADMINS').split(',').map((email) => email.trim()),
21
- signingKey: env('SIGNING_KEY'),
22
+ // The vault derives its signing key from the KEK. With a KEK a key service
23
+ // holds, such as awsKms(…), it needs one of its own: signingKey: env('SIGNING_KEY').
22
24
  });
23
25
  console.log(`the vault is listening on ${vault.socket}`);
24
26