@kazurolabs/psql-explorer 0.0.0-stage → 0.1.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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +294 -2
  3. package/dist/app.js +39 -0
  4. package/dist/cli.js +263 -0
  5. package/dist/commands/configgen.js +128 -0
  6. package/dist/commands/explore.js +306 -0
  7. package/dist/commands/terminal-prompt.js +68 -0
  8. package/dist/config/load.js +386 -0
  9. package/dist/config/resolve-environment.js +164 -0
  10. package/dist/config/resolvers/env.js +12 -0
  11. package/dist/config/resolvers/file.js +24 -0
  12. package/dist/config/resolvers/groups.js +1 -0
  13. package/dist/config/resolvers/index.js +49 -0
  14. package/dist/config/resolvers/literal.js +7 -0
  15. package/dist/config/resolvers/prompt.js +159 -0
  16. package/dist/config/resolvers/stored.js +13 -0
  17. package/dist/config/resolvers/types.js +18 -0
  18. package/dist/config/resolvers/vault.js +365 -0
  19. package/dist/config/schema.js +24 -0
  20. package/dist/config/source.js +105 -0
  21. package/dist/db/connect.js +55 -0
  22. package/dist/db/identifiers.js +6 -0
  23. package/dist/db/paths.js +14 -0
  24. package/dist/db/store.js +266 -0
  25. package/dist/db/tester.js +190 -0
  26. package/dist/db/validate.js +99 -0
  27. package/dist/environments.js +137 -0
  28. package/dist/explorer/catalog.js +383 -0
  29. package/dist/explorer/connect-entry.js +31 -0
  30. package/dist/explorer/explain.js +13 -0
  31. package/dist/explorer/format.js +134 -0
  32. package/dist/explorer/rows.js +50 -0
  33. package/dist/explorer/session.js +105 -0
  34. package/dist/prompts.js +52 -0
  35. package/dist/screens/environments.js +226 -0
  36. package/dist/screens/explorer.js +148 -0
  37. package/dist/screens/header.js +8 -0
  38. package/dist/screens/index.js +13 -0
  39. package/dist/screens/new-connection.js +178 -0
  40. package/dist/screens/panes/common.js +35 -0
  41. package/dist/screens/panes/history.js +36 -0
  42. package/dist/screens/panes/info.js +61 -0
  43. package/dist/screens/panes/query.js +113 -0
  44. package/dist/screens/panes/table.js +178 -0
  45. package/dist/screens/panes/tables.js +142 -0
  46. package/dist/screens/prompt-modal.js +18 -0
  47. package/dist/screens/record-view.js +42 -0
  48. package/dist/screens/result-grid.js +126 -0
  49. package/dist/screens/scroll.js +12 -0
  50. package/dist/screens/splash.js +76 -0
  51. package/dist/screens/terminal.js +25 -0
  52. package/dist/screens/types.js +1 -0
  53. package/dist/services.js +1 -0
  54. package/dist/startup.js +84 -0
  55. package/dist/web/server.js +355 -0
  56. package/package.json +67 -5
  57. package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
  58. package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
  59. package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
  60. package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
  61. package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
  62. package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
  63. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  64. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  65. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  66. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  67. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  68. package/web/dist/assets/index-BMLFCtFY.js +17 -0
  69. package/web/dist/assets/index-CMXJRL9O.css +2 -0
  70. package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
  71. package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
  72. package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
  73. package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
  74. package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
  75. package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
  76. package/web/dist/index.html +19 -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';
119
+ const remembered = write ? undefined : confirmed.get(ctx.envName);
120
+ if (remembered)
121
+ return remembered;
122
+ const field = 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,365 @@
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 leases = new Map();
264
+ const issued = new Map();
265
+ const text = (ctx, data, key, path) => {
266
+ const value = data[key];
267
+ if (typeof value !== 'string' || value === '') {
268
+ throw fail(ctx, `key ${key} not in the answer of ${path}`);
269
+ }
270
+ return value;
271
+ };
272
+ // Asks Vault for something new once per environment and path (every time when `reuse` is false),
273
+ // and remembers its lease so release() can revoke it. A lease that has run out is replaced.
274
+ const issue = async (ctx, path, request, read, reuse = true) => {
275
+ const cacheKey = `${ctx.envName}\0${path}`;
276
+ const cached = reuse ? issued.get(cacheKey) : undefined;
277
+ if (cached) {
278
+ const entry = await cached;
279
+ if (entry.expiresAt === undefined || now() < entry.expiresAt)
280
+ return entry.values;
281
+ if (issued.get(cacheKey) === cached)
282
+ issued.delete(cacheKey);
283
+ }
284
+ const pending = (async () => {
285
+ const body = await call(ctx, path, request, 'role');
286
+ const values = read((body.data ?? {}));
287
+ const leaseId = typeof body.lease_id === 'string' ? body.lease_id : '';
288
+ const duration = typeof body.lease_duration === 'number' ? body.lease_duration : 0;
289
+ const expiresAt = duration > 0 ? now() + duration * 1000 - expiryMarginMs : undefined;
290
+ if (leaseId) {
291
+ leases.set(ctx.envName, [...(leases.get(ctx.envName) ?? []), { id: leaseId, expiresAt }]);
292
+ }
293
+ return { values, expiresAt };
294
+ })();
295
+ if (reuse) {
296
+ issued.set(cacheKey, pending);
297
+ pending.catch(() => {
298
+ if (issued.get(cacheKey) === pending)
299
+ issued.delete(cacheKey);
300
+ });
301
+ }
302
+ return (await pending).values;
303
+ };
304
+ const revoke = async (envName) => {
305
+ const held = leases.get(envName) ?? [];
306
+ leases.delete(envName);
307
+ for (const key of [...issued.keys()])
308
+ if (key.startsWith(`${envName}\0`))
309
+ issued.delete(key);
310
+ const ctx = { envName, field: 'lease', configDir: '', interactive: false };
311
+ for (const lease of held) {
312
+ try {
313
+ await authed(ctx, 'sys/leases/revoke', { method: 'PUT', body: { lease_id: lease.id } });
314
+ }
315
+ catch {
316
+ // Best effort: a lease that cannot be revoked still ends when its TTL does.
317
+ }
318
+ }
319
+ };
320
+ const groups = {
321
+ async databaseCredentials(path, ctx) {
322
+ // Never reused: a password is asked for again every time it is needed.
323
+ const values = await issue(ctx, path, {}, (data) => ({
324
+ username: text(ctx, data, 'username', path),
325
+ password: text(ctx, data, 'password', path),
326
+ }), false);
327
+ return { username: values.username, password: values.password };
328
+ },
329
+ async clientCertificate(path, commonName, ctx) {
330
+ const values = await issue(ctx, path, { method: 'POST', body: { common_name: commonName } }, (data) => ({
331
+ cert: text(ctx, data, 'certificate', path),
332
+ key: text(ctx, data, 'private_key', path),
333
+ ...(typeof data.issuing_ca === 'string' && data.issuing_ca !== ''
334
+ ? { ca: data.issuing_ca }
335
+ : {}),
336
+ }));
337
+ return { cert: values.cert, key: values.key, ...(values.ca ? { ca: values.ca } : {}) };
338
+ },
339
+ release: revoke,
340
+ releaseAll: async () => {
341
+ for (const envName of [...leases.keys()])
342
+ await revoke(envName);
343
+ },
344
+ };
345
+ const resolver = {
346
+ kind: 'vault',
347
+ async resolve(ref, ctx) {
348
+ const { path, key } = split(ref.vault);
349
+ const data = await fetchSecret(path, ctx);
350
+ const value = data[key];
351
+ if (value === undefined || value === null) {
352
+ throw fail(ctx, `key ${key} not in ${path}`);
353
+ }
354
+ if (typeof value === 'object')
355
+ throw fail(ctx, `key ${key} in ${path} is not a string`);
356
+ const text = String(value);
357
+ if (text === '')
358
+ throw fail(ctx, `key ${key} in ${path} is empty`);
359
+ return text;
360
+ },
361
+ describe: ({ vault }) => `vault: ${vault}`,
362
+ };
363
+ return { resolver, groups, kv };
364
+ }
365
+ 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
+ }