@kazurolabs/psql-explorer 0.0.0-stage → 0.3.0
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/LICENSE +21 -0
- package/README.md +505 -2
- package/RELEASES.md +87 -0
- package/dist/app.js +39 -0
- package/dist/cli.js +307 -0
- package/dist/commands/configgen.js +128 -0
- package/dist/commands/explore.js +388 -0
- package/dist/commands/terminal-prompt.js +68 -0
- package/dist/config/load.js +324 -0
- package/dist/config/resolve-environment.js +112 -0
- package/dist/config/resolvers/env.js +12 -0
- package/dist/config/resolvers/file.js +24 -0
- package/dist/config/resolvers/groups.js +1 -0
- package/dist/config/resolvers/index.js +49 -0
- package/dist/config/resolvers/literal.js +7 -0
- package/dist/config/resolvers/prompt.js +159 -0
- package/dist/config/resolvers/stored.js +13 -0
- package/dist/config/resolvers/types.js +18 -0
- package/dist/config/resolvers/vault.js +283 -0
- package/dist/config/schema.js +24 -0
- package/dist/config/source.js +105 -0
- package/dist/db/connect.js +55 -0
- package/dist/db/identifiers.js +6 -0
- package/dist/db/paths.js +14 -0
- package/dist/db/store.js +323 -0
- package/dist/db/tester.js +190 -0
- package/dist/db/validate.js +99 -0
- package/dist/environments.js +140 -0
- package/dist/explorer/alter.js +493 -0
- package/dist/explorer/catalog.js +414 -0
- package/dist/explorer/connect-entry.js +42 -0
- package/dist/explorer/cypher-parse.js +478 -0
- package/dist/explorer/cypher.js +963 -0
- package/dist/explorer/ddl.js +502 -0
- package/dist/explorer/dependents.js +222 -0
- package/dist/explorer/design-load.js +254 -0
- package/dist/explorer/design.js +45 -0
- package/dist/explorer/edit.js +108 -0
- package/dist/explorer/explain.js +37 -0
- package/dist/explorer/filter.js +121 -0
- package/dist/explorer/format.js +134 -0
- package/dist/explorer/graph.js +679 -0
- package/dist/explorer/gucs.js +31 -0
- package/dist/explorer/profile.js +102 -0
- package/dist/explorer/references.js +95 -0
- package/dist/explorer/rows.js +64 -0
- package/dist/explorer/schema-diff.js +360 -0
- package/dist/explorer/search.js +309 -0
- package/dist/explorer/security.js +152 -0
- package/dist/explorer/seed.js +1376 -0
- package/dist/explorer/session.js +122 -0
- package/dist/explorer/stats.js +135 -0
- package/dist/explorer/timeout.js +13 -0
- package/dist/prompts.js +52 -0
- package/dist/screens/environments.js +229 -0
- package/dist/screens/explorer.js +145 -0
- package/dist/screens/header.js +8 -0
- package/dist/screens/index.js +13 -0
- package/dist/screens/new-connection.js +178 -0
- package/dist/screens/panes/common.js +35 -0
- package/dist/screens/panes/history.js +36 -0
- package/dist/screens/panes/info.js +61 -0
- package/dist/screens/panes/query.js +113 -0
- package/dist/screens/panes/table.js +178 -0
- package/dist/screens/panes/tables.js +142 -0
- package/dist/screens/prompt-modal.js +18 -0
- package/dist/screens/record-view.js +42 -0
- package/dist/screens/result-grid.js +126 -0
- package/dist/screens/scroll.js +12 -0
- package/dist/screens/splash.js +78 -0
- package/dist/screens/terminal.js +25 -0
- package/dist/screens/types.js +1 -0
- package/dist/services.js +1 -0
- package/dist/startup.js +84 -0
- package/dist/web/server.js +988 -0
- package/package.json +69 -5
- package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
- package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
- package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
- package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
- package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
- package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
- package/web/dist/assets/button-ClA70PLR.js +1 -0
- package/web/dist/assets/code-xml-CXkpHeU9.js +1 -0
- package/web/dist/assets/compare-view-DKl5l_Jw.js +2 -0
- package/web/dist/assets/copy-CCXzeW-s.js +1 -0
- package/web/dist/assets/cypher-view-Pd0WFH8F.js +13 -0
- package/web/dist/assets/design-view-CIHSmH4u.js +9 -0
- package/web/dist/assets/erd-view-CV7mFGAN.js +1 -0
- package/web/dist/assets/format-sql-BLj5_RDZ.js +5 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/graph-force-yBLyErpS.js +1 -0
- package/web/dist/assets/graph-link-OCo_s0zO.js +1 -0
- package/web/dist/assets/graph-view-sM1OlByf.js +3 -0
- package/web/dist/assets/index-CDcnUacl.js +10 -0
- package/web/dist/assets/index-OzJqnJWG.css +2 -0
- package/web/dist/assets/key-round-Cv_7QeED.js +1 -0
- package/web/dist/assets/loader-circle-BK_GMdvN.js +1 -0
- package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
- package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
- package/web/dist/assets/query-builder-view-ChNFb51g.js +4 -0
- package/web/dist/assets/rotate-ccw-CdCaf7Q6.js +1 -0
- package/web/dist/assets/route-DO6IZ7of.js +15 -0
- package/web/dist/assets/seed-view-DFNB8WHE.js +7 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
- package/web/dist/assets/shield-alert-CMS-3j4U.js +1 -0
- package/web/dist/assets/sliders-horizontal-Dg51yc-C.js +1 -0
- package/web/dist/assets/sql-tabs-B4jt6bQj.js +2 -0
- package/web/dist/assets/table-DfMz2f8M.js +1 -0
- package/web/dist/assets/triangle-alert-COGnHCD1.js +1 -0
- package/web/dist/assets/zone-D9dXk8tn.js +5 -0
- package/web/dist/index.html +26 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import { ResolveError } from './types.js';
|
|
3
|
+
/** The value needs asking for, and nobody can be asked (and no earlier answer is remembered). */
|
|
4
|
+
export class PromptRequiredError extends ResolveError {
|
|
5
|
+
name = 'PromptRequiredError';
|
|
6
|
+
constructor(envName, field) {
|
|
7
|
+
super(envName, field, 'needs a prompt; not available non-interactively');
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const maskedFields = new Set(['password', 'tls.keyPassphrase']);
|
|
11
|
+
// Short, common and unlike each other, so they are quick to read and type.
|
|
12
|
+
const words = [
|
|
13
|
+
'apple',
|
|
14
|
+
'arrow',
|
|
15
|
+
'badge',
|
|
16
|
+
'basin',
|
|
17
|
+
'beach',
|
|
18
|
+
'bench',
|
|
19
|
+
'berry',
|
|
20
|
+
'bison',
|
|
21
|
+
'blade',
|
|
22
|
+
'bloom',
|
|
23
|
+
'brick',
|
|
24
|
+
'bridge',
|
|
25
|
+
'cabin',
|
|
26
|
+
'candle',
|
|
27
|
+
'canyon',
|
|
28
|
+
'cedar',
|
|
29
|
+
'cliff',
|
|
30
|
+
'cloud',
|
|
31
|
+
'clover',
|
|
32
|
+
'comet',
|
|
33
|
+
'coral',
|
|
34
|
+
'crane',
|
|
35
|
+
'daisy',
|
|
36
|
+
'delta',
|
|
37
|
+
'ember',
|
|
38
|
+
'falcon',
|
|
39
|
+
'fern',
|
|
40
|
+
'field',
|
|
41
|
+
'flint',
|
|
42
|
+
'forest',
|
|
43
|
+
'garden',
|
|
44
|
+
'glacier',
|
|
45
|
+
'harbor',
|
|
46
|
+
'hazel',
|
|
47
|
+
'island',
|
|
48
|
+
'jungle',
|
|
49
|
+
'kettle',
|
|
50
|
+
'lantern',
|
|
51
|
+
'lemon',
|
|
52
|
+
'maple',
|
|
53
|
+
'meadow',
|
|
54
|
+
'mirror',
|
|
55
|
+
'novel',
|
|
56
|
+
'olive',
|
|
57
|
+
'orchid',
|
|
58
|
+
'otter',
|
|
59
|
+
'pebble',
|
|
60
|
+
'pepper',
|
|
61
|
+
'planet',
|
|
62
|
+
'pocket',
|
|
63
|
+
'quartz',
|
|
64
|
+
'rabbit',
|
|
65
|
+
'river',
|
|
66
|
+
'saddle',
|
|
67
|
+
'silver',
|
|
68
|
+
'spruce',
|
|
69
|
+
'summit',
|
|
70
|
+
'ticket',
|
|
71
|
+
'timber',
|
|
72
|
+
'velvet',
|
|
73
|
+
'willow',
|
|
74
|
+
'window',
|
|
75
|
+
'yellow',
|
|
76
|
+
'zebra',
|
|
77
|
+
];
|
|
78
|
+
const CONFIRM_ATTEMPTS = 3;
|
|
79
|
+
const challengeWords = () => {
|
|
80
|
+
const first = randomInt(words.length);
|
|
81
|
+
let second = randomInt(words.length - 1);
|
|
82
|
+
if (second >= first)
|
|
83
|
+
second += 1;
|
|
84
|
+
return `${words[first]} ${words[second]}`;
|
|
85
|
+
};
|
|
86
|
+
const sameWords = (typed, expected) => typed.trim().split(/\s+/).join(' ').toLowerCase() === expected;
|
|
87
|
+
const keyOf = (envName, field) => `${envName}\0${field}`;
|
|
88
|
+
// Answers are kept in memory for the rest of the process, so a field is asked for once.
|
|
89
|
+
export function createPromptResolver(prompter) {
|
|
90
|
+
const answers = new Map();
|
|
91
|
+
const confirmed = new Map();
|
|
92
|
+
return {
|
|
93
|
+
kind: 'prompt',
|
|
94
|
+
resolve(_ref, ctx) {
|
|
95
|
+
const key = keyOf(ctx.envName, ctx.field);
|
|
96
|
+
const remembered = answers.get(key);
|
|
97
|
+
if (remembered)
|
|
98
|
+
return remembered;
|
|
99
|
+
if (!ctx.interactive)
|
|
100
|
+
return Promise.reject(new PromptRequiredError(ctx.envName, ctx.field));
|
|
101
|
+
const answer = prompter
|
|
102
|
+
.ask({
|
|
103
|
+
envName: ctx.envName,
|
|
104
|
+
field: ctx.field,
|
|
105
|
+
masked: maskedFields.has(ctx.field),
|
|
106
|
+
signal: ctx.signal,
|
|
107
|
+
})
|
|
108
|
+
.catch((error) => {
|
|
109
|
+
answers.delete(key);
|
|
110
|
+
throw error instanceof ResolveError
|
|
111
|
+
? error
|
|
112
|
+
: new ResolveError(ctx.envName, ctx.field, 'the prompt failed');
|
|
113
|
+
});
|
|
114
|
+
answers.set(key, answer);
|
|
115
|
+
return answer;
|
|
116
|
+
},
|
|
117
|
+
confirm(ctx) {
|
|
118
|
+
const write = ctx.purpose === 'write' || ctx.purpose === 'drop';
|
|
119
|
+
const remembered = write ? undefined : confirmed.get(ctx.envName);
|
|
120
|
+
if (remembered)
|
|
121
|
+
return remembered;
|
|
122
|
+
const field = ctx.purpose === 'drop' ? 'drop' : write ? 'write' : 'confirm';
|
|
123
|
+
if (!ctx.interactive)
|
|
124
|
+
return Promise.reject(new PromptRequiredError(ctx.envName, field));
|
|
125
|
+
const challenge = challengeWords();
|
|
126
|
+
const attempt = async (left) => {
|
|
127
|
+
const typed = await prompter.ask({
|
|
128
|
+
envName: ctx.envName,
|
|
129
|
+
field,
|
|
130
|
+
masked: false,
|
|
131
|
+
challenge,
|
|
132
|
+
signal: ctx.signal,
|
|
133
|
+
});
|
|
134
|
+
if (sameWords(typed, challenge))
|
|
135
|
+
return;
|
|
136
|
+
if (left > 1)
|
|
137
|
+
return attempt(left - 1);
|
|
138
|
+
throw new ResolveError(ctx.envName, field, 'the words typed were not the ones shown');
|
|
139
|
+
};
|
|
140
|
+
const done = attempt(CONFIRM_ATTEMPTS).catch((error) => {
|
|
141
|
+
if (!write)
|
|
142
|
+
confirmed.delete(ctx.envName);
|
|
143
|
+
throw error instanceof ResolveError
|
|
144
|
+
? error
|
|
145
|
+
: new ResolveError(ctx.envName, field, 'the prompt failed');
|
|
146
|
+
});
|
|
147
|
+
if (!write)
|
|
148
|
+
confirmed.set(ctx.envName, done);
|
|
149
|
+
return done;
|
|
150
|
+
},
|
|
151
|
+
describe: () => 'prompt',
|
|
152
|
+
forget(envName) {
|
|
153
|
+
const prefix = keyOf(envName, '');
|
|
154
|
+
for (const key of answers.keys())
|
|
155
|
+
if (key.startsWith(prefix))
|
|
156
|
+
answers.delete(key);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ResolveError } from './types.js';
|
|
2
|
+
// The value is a secret the user keeps in the local store, under the given key.
|
|
3
|
+
export const createStoredResolver = (store) => ({
|
|
4
|
+
kind: 'stored',
|
|
5
|
+
resolve({ stored }, ctx) {
|
|
6
|
+
const value = store.getSecret(stored);
|
|
7
|
+
if (!value) {
|
|
8
|
+
return Promise.reject(new ResolveError(ctx.envName, ctx.field, `no stored secret named "${stored}"`));
|
|
9
|
+
}
|
|
10
|
+
return Promise.resolve(value);
|
|
11
|
+
},
|
|
12
|
+
describe: ({ stored }) => `stored: ${stored}`,
|
|
13
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// How a config `Value` becomes a string at connect time. Resolved values live in memory only:
|
|
2
|
+
// nothing here, including every error message, may contain one.
|
|
3
|
+
export class ResolveError extends Error {
|
|
4
|
+
envName;
|
|
5
|
+
field;
|
|
6
|
+
reason;
|
|
7
|
+
denied;
|
|
8
|
+
name = 'ResolveError';
|
|
9
|
+
constructor(envName, field, reason,
|
|
10
|
+
// The server refused this user: the value exists but they may not read it.
|
|
11
|
+
denied = false) {
|
|
12
|
+
super(`${envName}.${field}: ${reason}`);
|
|
13
|
+
this.envName = envName;
|
|
14
|
+
this.field = field;
|
|
15
|
+
this.reason = reason;
|
|
16
|
+
this.denied = denied;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ResolveError } from './types.js';
|
|
5
|
+
export const vaultTimeoutMs = 10_000;
|
|
6
|
+
// A token that expires within this many ms is replaced before it is used.
|
|
7
|
+
const expiryMarginMs = 30_000;
|
|
8
|
+
// "secret/data/db/prod#password" → the KV v2 API path and the key inside the secret's data.
|
|
9
|
+
const split = (ref) => {
|
|
10
|
+
const at = ref.lastIndexOf('#');
|
|
11
|
+
return { path: ref.slice(0, at), key: ref.slice(at + 1) };
|
|
12
|
+
};
|
|
13
|
+
// "secret/data/psql-explorer/prod" → "secret/metadata/psql-explorer/prod", where listing happens.
|
|
14
|
+
export function metadataPath(dataPath) {
|
|
15
|
+
const parts = dataPath.split('/');
|
|
16
|
+
const at = parts.indexOf('data', 1);
|
|
17
|
+
if (at < 1)
|
|
18
|
+
return undefined;
|
|
19
|
+
parts[at] = 'metadata';
|
|
20
|
+
return parts.join('/');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Values from a HashiCorp Vault KV v2 secret: `source: vault` with `value: "<api path>#<key>"`, for example
|
|
24
|
+
* `secret/data/db/prod#password`. Needs VAULT_ADDR (VAULT_NAMESPACE is sent when set) and a token,
|
|
25
|
+
* taken from the first of these that is available:
|
|
26
|
+
*
|
|
27
|
+
* 1. VAULT_TOKEN;
|
|
28
|
+
* 2. an AppRole login, when VAULT_ROLE_ID and VAULT_SECRET_ID are set;
|
|
29
|
+
* 3. a JWT login (`auth/jwt/login`), when VAULT_JWT and VAULT_ROLE are set;
|
|
30
|
+
* 4. the token in ~/.vault-token.
|
|
31
|
+
*
|
|
32
|
+
* A token from a login is held in memory only, and replaced by a new login when it has expired.
|
|
33
|
+
* A secret is fetched from Vault every time a value is looked up and never kept, so a rotated
|
|
34
|
+
* password is seen by the next use. No error message contains a value from Vault.
|
|
35
|
+
*/
|
|
36
|
+
export function createVaultClient(options = {}) {
|
|
37
|
+
const environment = () => options.env ?? process.env;
|
|
38
|
+
const tokenFile = options.tokenFile ?? join(homedir(), '.vault-token');
|
|
39
|
+
const timeoutMs = options.timeoutMs ?? vaultTimeoutMs;
|
|
40
|
+
const doFetch = options.fetch ?? fetch;
|
|
41
|
+
const now = options.now ?? Date.now;
|
|
42
|
+
let current;
|
|
43
|
+
const fail = (ctx, reason, denied = false) => new ResolveError(ctx.envName, ctx.field, reason, denied);
|
|
44
|
+
const address = (ctx) => {
|
|
45
|
+
const value = environment().VAULT_ADDR?.replace(/\/+$/, '');
|
|
46
|
+
if (!value)
|
|
47
|
+
throw fail(ctx, 'VAULT_ADDR is not set');
|
|
48
|
+
return value;
|
|
49
|
+
};
|
|
50
|
+
// One request to Vault, with the timeout, the lookup's abort signal and readable failures.
|
|
51
|
+
const send = async (ctx, path, init) => {
|
|
52
|
+
const base = address(ctx);
|
|
53
|
+
const headers = {};
|
|
54
|
+
if (init.token)
|
|
55
|
+
headers['X-Vault-Token'] = init.token;
|
|
56
|
+
const namespace = environment().VAULT_NAMESPACE;
|
|
57
|
+
if (namespace)
|
|
58
|
+
headers['X-Vault-Namespace'] = namespace;
|
|
59
|
+
if (init.body !== undefined)
|
|
60
|
+
headers['Content-Type'] = 'application/json';
|
|
61
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
62
|
+
const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeout]) : timeout;
|
|
63
|
+
try {
|
|
64
|
+
return await doFetch(`${base}/v1/${path}`, {
|
|
65
|
+
method: init.method ?? 'GET',
|
|
66
|
+
headers,
|
|
67
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
68
|
+
signal,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (ctx.signal?.aborted)
|
|
73
|
+
throw fail(ctx, 'cancelled');
|
|
74
|
+
if (timeout.aborted) {
|
|
75
|
+
throw fail(ctx, `Vault at ${base} did not answer within ${timeoutMs / 1000}s`);
|
|
76
|
+
}
|
|
77
|
+
const code = error.cause?.code;
|
|
78
|
+
throw fail(ctx, `cannot reach Vault at ${base}${code ? ` (${code})` : ''}`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const login = async (ctx, method, path, body) => {
|
|
82
|
+
const response = await send(ctx, path, { method: 'POST', body });
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
// Vault's own message is left out: it can echo what was sent.
|
|
85
|
+
throw fail(ctx, `Vault ${method} login was refused (HTTP ${response.status})`);
|
|
86
|
+
}
|
|
87
|
+
let auth;
|
|
88
|
+
try {
|
|
89
|
+
auth = (await response.json()).auth;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Reported below.
|
|
93
|
+
}
|
|
94
|
+
if (typeof auth?.client_token !== 'string' || auth.client_token === '') {
|
|
95
|
+
throw fail(ctx, `Vault ${method} login returned no token`);
|
|
96
|
+
}
|
|
97
|
+
const lease = typeof auth.lease_duration === 'number' ? auth.lease_duration : 0;
|
|
98
|
+
return {
|
|
99
|
+
value: auth.client_token,
|
|
100
|
+
expiresAt: lease > 0 ? now() + lease * 1000 - expiryMarginMs : undefined,
|
|
101
|
+
fromLogin: true,
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
const acquire = async (ctx) => {
|
|
105
|
+
const settings = environment();
|
|
106
|
+
if (settings.VAULT_TOKEN)
|
|
107
|
+
return { value: settings.VAULT_TOKEN, fromLogin: false };
|
|
108
|
+
if (settings.VAULT_ROLE_ID && settings.VAULT_SECRET_ID) {
|
|
109
|
+
return login(ctx, 'AppRole', 'auth/approle/login', {
|
|
110
|
+
role_id: settings.VAULT_ROLE_ID,
|
|
111
|
+
secret_id: settings.VAULT_SECRET_ID,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (settings.VAULT_JWT && settings.VAULT_ROLE) {
|
|
115
|
+
return login(ctx, 'JWT', 'auth/jwt/login', {
|
|
116
|
+
role: settings.VAULT_ROLE,
|
|
117
|
+
jwt: settings.VAULT_JWT,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const content = (await readFile(tokenFile, 'utf8')).trim();
|
|
122
|
+
if (content)
|
|
123
|
+
return { value: content, fromLogin: false };
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Reported below, the same as an empty file.
|
|
127
|
+
}
|
|
128
|
+
throw fail(ctx, 'no Vault token: set VAULT_TOKEN, VAULT_ROLE_ID and VAULT_SECRET_ID, VAULT_JWT and VAULT_ROLE, or log in with the vault CLI (~/.vault-token)');
|
|
129
|
+
};
|
|
130
|
+
// The token in use; a login that has expired, or that was refused, is done again.
|
|
131
|
+
const token = async (ctx, replace) => {
|
|
132
|
+
const seen = current;
|
|
133
|
+
if (seen) {
|
|
134
|
+
const held = await seen.catch(() => undefined);
|
|
135
|
+
const usable = held && (held.expiresAt === undefined || now() < held.expiresAt);
|
|
136
|
+
// `replace` was refused; if another lookup has already got a new token, use that.
|
|
137
|
+
if (usable && held !== replace)
|
|
138
|
+
return held;
|
|
139
|
+
if (current === seen)
|
|
140
|
+
current = undefined;
|
|
141
|
+
}
|
|
142
|
+
current ??= acquire(ctx);
|
|
143
|
+
const started = current;
|
|
144
|
+
started.catch(() => {
|
|
145
|
+
if (current === started)
|
|
146
|
+
current = undefined;
|
|
147
|
+
});
|
|
148
|
+
return started;
|
|
149
|
+
};
|
|
150
|
+
// An authenticated request. A token from a login can be revoked or expire early: log in again,
|
|
151
|
+
// once.
|
|
152
|
+
const authed = async (ctx, path, init, used, canRetry = true) => {
|
|
153
|
+
address(ctx);
|
|
154
|
+
const held = used ?? (await token(ctx, undefined));
|
|
155
|
+
const response = await send(ctx, path, { ...init, token: held.value });
|
|
156
|
+
if (response.status === 403 && held.fromLogin && canRetry) {
|
|
157
|
+
return authed(ctx, path, init, await token(ctx, held), false);
|
|
158
|
+
}
|
|
159
|
+
if (response.status === 403 && !held.fromLogin && !(await tokenWorks(ctx, held))) {
|
|
160
|
+
// An expired or revoked token also answers 403, which would pass for missing access.
|
|
161
|
+
// Forget it, so a `vault login` made meanwhile is picked up by the next lookup.
|
|
162
|
+
current = undefined;
|
|
163
|
+
throw fail(ctx, 'the Vault token is expired or not valid: log in again (vault login) or set a new VAULT_TOKEN');
|
|
164
|
+
}
|
|
165
|
+
return response;
|
|
166
|
+
};
|
|
167
|
+
// Whether Vault still accepts a token we were given and cannot renew. Every valid token may look
|
|
168
|
+
// itself up, so a refusal here means the token is bad; any other answer is taken as good.
|
|
169
|
+
const checked = new WeakMap();
|
|
170
|
+
const tokenWorks = (ctx, held) => {
|
|
171
|
+
let result = checked.get(held);
|
|
172
|
+
if (!result) {
|
|
173
|
+
result = send(ctx, 'auth/token/lookup-self', { token: held.value }).then((response) => response.status !== 403 && response.status !== 401);
|
|
174
|
+
checked.set(held, result);
|
|
175
|
+
result.catch(() => checked.delete(held));
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
};
|
|
179
|
+
// The JSON body of a successful answer; other answers become readable errors.
|
|
180
|
+
const call = async (ctx, path, init, what) => {
|
|
181
|
+
const response = await authed(ctx, path, init);
|
|
182
|
+
if (response.status === 403)
|
|
183
|
+
throw fail(ctx, `permission denied for ${path}`, true);
|
|
184
|
+
if (response.status === 404)
|
|
185
|
+
throw fail(ctx, `${what} ${path} not found`);
|
|
186
|
+
if (!response.ok)
|
|
187
|
+
throw fail(ctx, `Vault returned HTTP ${response.status} for ${path}`);
|
|
188
|
+
try {
|
|
189
|
+
const body = (await response.json());
|
|
190
|
+
if (typeof body === 'object' && body !== null)
|
|
191
|
+
return body;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Reported below.
|
|
195
|
+
}
|
|
196
|
+
throw fail(ctx, `unexpected response from Vault for ${path}`);
|
|
197
|
+
};
|
|
198
|
+
const fetchSecret = async (path, ctx) => {
|
|
199
|
+
const body = await call(ctx, path, {}, 'secret');
|
|
200
|
+
const data = body.data?.data;
|
|
201
|
+
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
|
|
202
|
+
return data;
|
|
203
|
+
}
|
|
204
|
+
throw fail(ctx, `unexpected response from Vault for ${path} (is it a KV v2 path, "…/data/…"?)`);
|
|
205
|
+
};
|
|
206
|
+
// --- The config's own secrets: read, list and write ---
|
|
207
|
+
const kvContext = (path, signal) => ({
|
|
208
|
+
envName: 'vault',
|
|
209
|
+
field: path,
|
|
210
|
+
configDir: '',
|
|
211
|
+
interactive: false,
|
|
212
|
+
signal,
|
|
213
|
+
});
|
|
214
|
+
const kv = {
|
|
215
|
+
async read(path, signal) {
|
|
216
|
+
const ctx = kvContext(path, signal);
|
|
217
|
+
const response = await authed(ctx, path, {});
|
|
218
|
+
if (response.status === 404)
|
|
219
|
+
return undefined;
|
|
220
|
+
if (response.status === 403)
|
|
221
|
+
throw fail(ctx, `permission denied for ${path}`, true);
|
|
222
|
+
if (!response.ok)
|
|
223
|
+
throw fail(ctx, `Vault returned HTTP ${response.status} for ${path}`);
|
|
224
|
+
let body;
|
|
225
|
+
try {
|
|
226
|
+
body = (await response.json());
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Reported below.
|
|
230
|
+
}
|
|
231
|
+
const data = body?.data?.data;
|
|
232
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
233
|
+
// A soft-deleted version answers 404 with a body; anything else here is the wrong path type.
|
|
234
|
+
throw fail(ctx, `unexpected response from Vault for ${path} (is it a KV v2 path, "…/data/…"?)`);
|
|
235
|
+
}
|
|
236
|
+
const version = body?.data?.metadata?.version;
|
|
237
|
+
return { data: data, version: typeof version === 'number' ? version : 0 };
|
|
238
|
+
},
|
|
239
|
+
async list(path, signal) {
|
|
240
|
+
const ctx = kvContext(path, signal);
|
|
241
|
+
const metadata = metadataPath(path);
|
|
242
|
+
if (!metadata) {
|
|
243
|
+
throw fail(ctx, `${path} is not a KV v2 path (expected "<mount>/data/<path>")`);
|
|
244
|
+
}
|
|
245
|
+
const response = await authed(ctx, `${metadata}?list=true`, {});
|
|
246
|
+
if (response.status === 404)
|
|
247
|
+
return [];
|
|
248
|
+
if (response.status === 403)
|
|
249
|
+
throw fail(ctx, `permission denied to list ${metadata}`, true);
|
|
250
|
+
if (!response.ok)
|
|
251
|
+
throw fail(ctx, `Vault returned HTTP ${response.status} for ${metadata}`);
|
|
252
|
+
try {
|
|
253
|
+
const keys = (await response.json()).data?.keys;
|
|
254
|
+
if (Array.isArray(keys))
|
|
255
|
+
return keys.filter((key) => typeof key === 'string');
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// Reported below.
|
|
259
|
+
}
|
|
260
|
+
throw fail(ctx, `unexpected response from Vault for ${metadata}`);
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
const resolver = {
|
|
264
|
+
kind: 'vault',
|
|
265
|
+
async resolve(ref, ctx) {
|
|
266
|
+
const { path, key } = split(ref.vault);
|
|
267
|
+
const data = await fetchSecret(path, ctx);
|
|
268
|
+
const value = data[key];
|
|
269
|
+
if (value === undefined || value === null) {
|
|
270
|
+
throw fail(ctx, `key ${key} not in ${path}`);
|
|
271
|
+
}
|
|
272
|
+
if (typeof value === 'object')
|
|
273
|
+
throw fail(ctx, `key ${key} in ${path} is not a string`);
|
|
274
|
+
const text = String(value);
|
|
275
|
+
if (text === '')
|
|
276
|
+
throw fail(ctx, `key ${key} in ${path} is empty`);
|
|
277
|
+
return text;
|
|
278
|
+
},
|
|
279
|
+
describe: ({ vault }) => `vault: ${vault}`,
|
|
280
|
+
};
|
|
281
|
+
return { resolver, kv };
|
|
282
|
+
}
|
|
283
|
+
export const createVaultResolver = (options = {}) => createVaultClient(options).resolver;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The shared config, parsed and validated but not resolved: a field is either a literal or a
|
|
2
|
+
// reference to where its value comes from, which is looked up at connect time. It is a base path
|
|
3
|
+
// holding one `<environment>/explorer.yaml` per environment, either in a directory or in Vault
|
|
4
|
+
// (see source.ts).
|
|
5
|
+
export const tiers = ['production', 'reference', 'demo', 'development', 'local'];
|
|
6
|
+
export const authModes = ['password', 'none'];
|
|
7
|
+
export const tlsModes = ['disable', 'require', 'verify-ca', 'verify-full'];
|
|
8
|
+
export const refKinds = ['env', 'file', 'prompt', 'stored', 'vault'];
|
|
9
|
+
export const sourceNames = ['plaintext', 'inline', ...refKinds];
|
|
10
|
+
// The schema browsing starts in when an environment does not name one.
|
|
11
|
+
export const defaultSchemaName = 'public';
|
|
12
|
+
export const describeProblem = (problem) => `${problem.path || 'config'}${problem.line === undefined ? '' : ` (line ${problem.line})`}: ${problem.message}`;
|
|
13
|
+
export class ConfigError extends Error {
|
|
14
|
+
file;
|
|
15
|
+
problems;
|
|
16
|
+
name = 'ConfigError';
|
|
17
|
+
constructor(
|
|
18
|
+
// What was being read: the base path, or one file of it.
|
|
19
|
+
file, problems) {
|
|
20
|
+
super(`Invalid config ${file}:\n${problems.map((problem) => ` ${describeProblem(problem)}`).join('\n')}`);
|
|
21
|
+
this.file = file;
|
|
22
|
+
this.problems = problems;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Where the shared config is stored. A base path holds one `<environment>/explorer.yaml` per
|
|
2
|
+
// environment, in a directory or in Vault:
|
|
3
|
+
//
|
|
4
|
+
// ./config vault:secret/data/psql-explorer
|
|
5
|
+
// ./config/prod/explorer.yaml secret/data/psql-explorer/prod key explorer.yaml
|
|
6
|
+
//
|
|
7
|
+
// Listing the base is how environments are discovered, so what a person can see in Vault follows
|
|
8
|
+
// their policy. Nothing here parses the YAML.
|
|
9
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
11
|
+
import { metadataPath, ResolveError } from './resolvers/index.js';
|
|
12
|
+
export const configFileName = 'explorer.yaml';
|
|
13
|
+
export const vaultPrefix = 'vault:';
|
|
14
|
+
/** The config could not be read; `denied` when Vault refuses this user. */
|
|
15
|
+
export class SourceError extends Error {
|
|
16
|
+
denied;
|
|
17
|
+
name = 'SourceError';
|
|
18
|
+
constructor(message, denied = false) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.denied = denied;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const reason = (error) => (error instanceof Error ? error.message : String(error));
|
|
24
|
+
const localSource = (base) => {
|
|
25
|
+
const fileOf = (name) => join(base, name, configFileName);
|
|
26
|
+
const read = async (path, dir) => {
|
|
27
|
+
try {
|
|
28
|
+
return { text: await readFile(path, 'utf8'), label: path, dir };
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === 'ENOENT')
|
|
32
|
+
return undefined;
|
|
33
|
+
throw new SourceError(`cannot read ${path}: ${reason(error)}`);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
base,
|
|
38
|
+
async list() {
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(base, { withFileTypes: true });
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
const code = error.code;
|
|
45
|
+
throw new SourceError(code === 'ENOENT' ? 'directory not found' : `cannot read the directory: ${reason(error)}`);
|
|
46
|
+
}
|
|
47
|
+
const names = [];
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
50
|
+
continue;
|
|
51
|
+
// Folders without an explorer.yaml (certificates, say) are not environments.
|
|
52
|
+
if (await read(fileOf(entry.name), join(base, entry.name)))
|
|
53
|
+
names.push(entry.name);
|
|
54
|
+
}
|
|
55
|
+
return names.sort();
|
|
56
|
+
},
|
|
57
|
+
readEnvironment: (name) => read(fileOf(name), join(base, name)),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
const vaultSource = (path, vault) => {
|
|
61
|
+
const base = `${vaultPrefix}${path}`;
|
|
62
|
+
const secretOf = (name) => `${path}/${name}`;
|
|
63
|
+
const labelOf = (name) => `${secretOf(name)}#${configFileName}`;
|
|
64
|
+
// Vault says why in a ResolveError; only the reason is wanted here.
|
|
65
|
+
const wrap = async (work) => {
|
|
66
|
+
try {
|
|
67
|
+
return await work;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof ResolveError)
|
|
71
|
+
throw new SourceError(error.reason, error.denied);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const read = async (name) => {
|
|
76
|
+
const secret = await wrap(vault.kv.read(secretOf(name)));
|
|
77
|
+
if (!secret)
|
|
78
|
+
return undefined;
|
|
79
|
+
const text = secret.data[configFileName];
|
|
80
|
+
if (typeof text !== 'string') {
|
|
81
|
+
throw new SourceError(`${secretOf(name)} has no ${configFileName} key`);
|
|
82
|
+
}
|
|
83
|
+
return { text, label: labelOf(name), dir: process.cwd() };
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
base,
|
|
87
|
+
async list() {
|
|
88
|
+
const keys = await wrap(vault.kv.list(path));
|
|
89
|
+
// A trailing "/" is a folder; an environment is a secret of its own.
|
|
90
|
+
return keys.filter((key) => !key.endsWith('/')).sort();
|
|
91
|
+
},
|
|
92
|
+
readEnvironment: (name) => read(name),
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
/** The source for a base path: "vault:<KV v2 data path>", else a directory. Throws SourceError if it is malformed. */
|
|
96
|
+
export function createSource(base, vault) {
|
|
97
|
+
if (base.startsWith(vaultPrefix)) {
|
|
98
|
+
const path = base.slice(vaultPrefix.length).replace(/^\/+|\/+$/g, '');
|
|
99
|
+
if (!path || !metadataPath(path)) {
|
|
100
|
+
throw new SourceError('a Vault base path looks like vault:secret/data/psql-explorer (a KV v2 "<mount>/data/<path>")');
|
|
101
|
+
}
|
|
102
|
+
return vaultSource(path, vault);
|
|
103
|
+
}
|
|
104
|
+
return localSource(resolve(base));
|
|
105
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
3
|
+
export const DEFAULT_STATEMENT_TIMEOUT_MS = 30_000;
|
|
4
|
+
// Every value comes back as the text Postgres sends, as psql shows it: no Date shifted into the
|
|
5
|
+
// local time zone, no bigint rounded through a JS number.
|
|
6
|
+
const asText = (value) => value;
|
|
7
|
+
export const textTypes = {
|
|
8
|
+
getTypeParser: (() => asText),
|
|
9
|
+
};
|
|
10
|
+
/** Maps TLS settings to the `ssl` option of the pg driver. */
|
|
11
|
+
export function sslOptions(tls) {
|
|
12
|
+
if (tls.mode === 'disable')
|
|
13
|
+
return false;
|
|
14
|
+
const client = {
|
|
15
|
+
...(tls.cert === undefined ? {} : { cert: tls.cert }),
|
|
16
|
+
...(tls.key === undefined ? {} : { key: tls.key }),
|
|
17
|
+
...(tls.keyPassphrase === undefined ? {} : { passphrase: tls.keyPassphrase }),
|
|
18
|
+
};
|
|
19
|
+
if (tls.mode === 'require')
|
|
20
|
+
return { rejectUnauthorized: false, ...client };
|
|
21
|
+
const verified = {
|
|
22
|
+
rejectUnauthorized: true,
|
|
23
|
+
...(tls.ca === undefined ? {} : { ca: tls.ca }),
|
|
24
|
+
...client,
|
|
25
|
+
};
|
|
26
|
+
// verify-ca checks the chain only, not that the certificate names this host.
|
|
27
|
+
return tls.mode === 'verify-ca' ? { ...verified, checkServerIdentity: () => undefined } : verified;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Builds, without connecting, the pg client every part of the explorer uses to reach a database.
|
|
31
|
+
* The caller connects it and must end it.
|
|
32
|
+
*/
|
|
33
|
+
export function createClient(params, { timeoutMs = DEFAULT_TIMEOUT_MS, statementTimeoutMs = DEFAULT_STATEMENT_TIMEOUT_MS, readOnly = false, } = {}) {
|
|
34
|
+
// Startup parameters, so they hold from the first statement on.
|
|
35
|
+
const settings = [
|
|
36
|
+
...(readOnly ? ['default_transaction_read_only=on'] : []),
|
|
37
|
+
...(statementTimeoutMs === null ? [] : [`statement_timeout=${statementTimeoutMs}`]),
|
|
38
|
+
];
|
|
39
|
+
const client = new pg.Client({
|
|
40
|
+
host: params.host,
|
|
41
|
+
port: params.port,
|
|
42
|
+
database: params.database,
|
|
43
|
+
user: params.username,
|
|
44
|
+
password: params.password,
|
|
45
|
+
ssl: sslOptions(params.tls),
|
|
46
|
+
connectionTimeoutMillis: timeoutMs,
|
|
47
|
+
application_name: 'psql-explorer',
|
|
48
|
+
types: textTypes,
|
|
49
|
+
...(settings.length > 0 ? { options: settings.map((setting) => `-c ${setting}`).join(' ') } : {}),
|
|
50
|
+
});
|
|
51
|
+
// A connection the server drops later (a restart, an idle timeout) emits 'error'; without a
|
|
52
|
+
// listener that would crash the process. The next query reports it instead.
|
|
53
|
+
client.on('error', () => undefined);
|
|
54
|
+
return client;
|
|
55
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Quotes a Postgres identifier (schema, table...), so a name from config can never alter a query. */
|
|
2
|
+
export function quoteIdentifier(name) {
|
|
3
|
+
if (!name || name.includes('\0'))
|
|
4
|
+
throw new Error(`Invalid identifier "${name}"`);
|
|
5
|
+
return `"${name.replaceAll('"', '""')}"`;
|
|
6
|
+
}
|
package/dist/db/paths.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const databaseFileName = 'explorer.db';
|
|
4
|
+
export const configVariable = 'PSQL_EXPLORER_CONFIG';
|
|
5
|
+
// PSQL_EXPLORER_CONFIG (the directory holding explorer.db) wins, then the XDG config dir, then
|
|
6
|
+
// ~/.config. Empty values count as unset, as the XDG spec asks.
|
|
7
|
+
export function dataDir(env = process.env, home = homedir()) {
|
|
8
|
+
if (env[configVariable])
|
|
9
|
+
return env[configVariable];
|
|
10
|
+
if (env.XDG_CONFIG_HOME)
|
|
11
|
+
return join(env.XDG_CONFIG_HOME, 'psql-explorer');
|
|
12
|
+
return join(home, '.config', 'psql-explorer');
|
|
13
|
+
}
|
|
14
|
+
export const databaseFile = (dir) => join(dir, databaseFileName);
|