@coffre/cli 0.1.2 → 0.1.4
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/dist/keys-RhB7ayOY.js +726 -0
- package/dist/main.js +6 -6
- package/dist/setup-3OJc_VCA.js +756 -0
- package/dist/templates/node/README.md +20 -17
- package/dist/templates/node/package.json +3 -3
- package/dist/templates/node/server.env.example +3 -3
- package/dist/templates/node/src/server.ts +2 -2
- package/dist/templates/node/src/vault.ts +7 -7
- package/dist/templates/node/vault.env.example +6 -5
- package/dist/templates/workers/README.md +28 -23
- package/dist/templates/workers/app/src/worker.ts +2 -2
- package/dist/templates/workers/app/wrangler.jsonc +4 -4
- package/dist/templates/workers/package.json +4 -4
- package/dist/templates/workers/vault/src/worker.ts +8 -8
- package/dist/templates/workers/vault/wrangler.jsonc +8 -7
- package/package.json +4 -4
- package/dist/keys-D9CG_wYV.js +0 -64
- package/dist/setup-DPHVyr2N.js +0 -521
package/dist/setup-DPHVyr2N.js
DELETED
|
@@ -1,521 +0,0 @@
|
|
|
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 };
|