@catalystiq/test-kit 0.3.1 → 0.4.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/README.md +10 -3
- package/dist/bin/check-install-scripts.d.ts +2 -0
- package/dist/bin/check-install-scripts.js +8 -0
- package/dist/env/README.md +8 -2
- package/dist/env/dev-guard.d.ts +7 -6
- package/dist/env/dev-guard.js +28 -26
- package/dist/install/README.md +55 -0
- package/dist/install/index.d.ts +97 -0
- package/dist/install/index.js +460 -0
- package/dist/neon-endpoint/README.md +57 -0
- package/dist/neon-endpoint/index.d.ts +112 -0
- package/dist/neon-endpoint/index.js +359 -0
- package/dist/setup/README.md +1 -1
- package/dist/setup/database.d.ts +35 -3
- package/dist/setup/database.js +227 -25
- package/dist/setup/network.js +1 -1
- package/package.json +14 -4
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The install hardening every getcatalystiq app keeps, checked from the app's own files (`tk-check-install-scripts`,
|
|
3
|
+
* or {@link checkInstallHardening} from a test):
|
|
4
|
+
*
|
|
5
|
+
* - package.json's `allowScripts` names exactly the locked packages that have an install script, each `true` (run) or
|
|
6
|
+
* `false` (skipped), and the ones that run are the ones the app says run;
|
|
7
|
+
* - `.npmrc` sets `min-release-age` and `strict-allow-scripts=true` (and no `legacy-peer-deps`), the npm on PATH knows
|
|
8
|
+
* both keys and takes them from the project's file, and it refuses an install whose tree has a script the list does
|
|
9
|
+
* not name, before running it;
|
|
10
|
+
* - the lockfile pins every package it fetches by registry URL and sha512;
|
|
11
|
+
* - the app's npm floor script (run before `npm ci` where no CI job watches the install) admits the floor and refuses
|
|
12
|
+
* anything older, a prerelease and a string that is no version, and the host's install command runs it first;
|
|
13
|
+
* - npm is the package manager: one lockfile, version 3, tracked; `packageManager` names an npm the floor admits, and
|
|
14
|
+
* under `.nvmrc`'s own Node exactly the npm in use.
|
|
15
|
+
*
|
|
16
|
+
* It reports what is wrong, one line each, and what it could not check (`skipped`): a check that did not run is never
|
|
17
|
+
* reported as passed. It reads files and runs `npm`, `node` and `git`; it imports no test runner.
|
|
18
|
+
*/
|
|
19
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
20
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { delimiter, dirname, join, resolve } from 'node:path';
|
|
23
|
+
/** The names of the locked packages that run an install script. */
|
|
24
|
+
export function withInstallScripts(lock) {
|
|
25
|
+
const names = new Set();
|
|
26
|
+
for (const [path, entry] of Object.entries(lock.packages ?? {})) {
|
|
27
|
+
if (path === '' || entry.link || !entry.hasInstallScript)
|
|
28
|
+
continue;
|
|
29
|
+
// A path with no node_modules/ segment (a workspace folder) has no name to cut from it.
|
|
30
|
+
const at = path.lastIndexOf('node_modules/');
|
|
31
|
+
names.add(entry.name ?? (at === -1 ? path : path.slice(at + 'node_modules/'.length)));
|
|
32
|
+
}
|
|
33
|
+
return [...names].sort();
|
|
34
|
+
}
|
|
35
|
+
/** What is wrong between a lockfile and an `allowScripts`, one line each. */
|
|
36
|
+
export function scriptProblems(lock, allow) {
|
|
37
|
+
const problems = [];
|
|
38
|
+
const needed = withInstallScripts(lock);
|
|
39
|
+
for (const name of needed) {
|
|
40
|
+
if (!Object.hasOwn(allow, name))
|
|
41
|
+
problems.push(`unreviewed install script: ${name} (name it in package.json allowScripts, true or false)`);
|
|
42
|
+
}
|
|
43
|
+
for (const [key, value] of Object.entries(allow)) {
|
|
44
|
+
if (typeof value !== 'boolean')
|
|
45
|
+
problems.push(`allowScripts.${key} is not true or false`);
|
|
46
|
+
if (!needed.includes(key))
|
|
47
|
+
problems.push(`stale allowScripts entry: ${key} (no locked package of that name has an install script)`);
|
|
48
|
+
}
|
|
49
|
+
return problems;
|
|
50
|
+
}
|
|
51
|
+
/** The lockfile paths npm would fetch that are not pinned to `registry` and a sha512. */
|
|
52
|
+
export function unpinnedPackages(lock, registry = 'https://registry.npmjs.org/') {
|
|
53
|
+
const loose = [];
|
|
54
|
+
for (const [path, entry] of Object.entries(lock.packages ?? {})) {
|
|
55
|
+
// The root, workspace links and a tarball's own bundled packages are never fetched on their own.
|
|
56
|
+
if (path === '' || entry.link || entry.inBundle)
|
|
57
|
+
continue;
|
|
58
|
+
if (!entry.resolved?.startsWith(registry) || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(entry.integrity ?? ''))
|
|
59
|
+
loose.push(path);
|
|
60
|
+
}
|
|
61
|
+
return loose;
|
|
62
|
+
}
|
|
63
|
+
/** `X.Y.Z` as numbers, or null: a prerelease (`11.19.1-rc.0`) is below its release, and anything else is no version. */
|
|
64
|
+
export function releaseVersion(version) {
|
|
65
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
|
|
66
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
67
|
+
}
|
|
68
|
+
/** Whether `version` is a release at or above `floor`. */
|
|
69
|
+
export function atLeast(version, floor) {
|
|
70
|
+
const have = releaseVersion(version);
|
|
71
|
+
const want = releaseVersion(floor);
|
|
72
|
+
if (!have || !want)
|
|
73
|
+
return false;
|
|
74
|
+
for (let i = 0; i < 3; i += 1) {
|
|
75
|
+
if (have[i] !== want[i])
|
|
76
|
+
return have[i] > want[i];
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
/** The versions a floor script must admit and refuse, from the floor itself. */
|
|
81
|
+
export function floorCases(floor, prereleases = true) {
|
|
82
|
+
const parts = releaseVersion(floor);
|
|
83
|
+
if (!parts)
|
|
84
|
+
throw new Error(`the npm floor ${JSON.stringify(floor)} is not an X.Y.Z version`);
|
|
85
|
+
const [major, minor, patch] = parts;
|
|
86
|
+
const admitted = [floor, `${major}.${minor + 1}.0`, `${major + 1}.0.0`];
|
|
87
|
+
const refused = ['not-a-version'];
|
|
88
|
+
if (patch > 0)
|
|
89
|
+
refused.push(`${major}.${minor}.${patch - 1}`);
|
|
90
|
+
if (minor > 0)
|
|
91
|
+
refused.push(`${major}.${minor - 1}.99`);
|
|
92
|
+
if (major > 0)
|
|
93
|
+
refused.push(`${major - 1}.99.0`);
|
|
94
|
+
if (prereleases)
|
|
95
|
+
refused.push(`${floor}-rc.0`, `${major + 1}.0.0-pre`);
|
|
96
|
+
return { admitted, refused };
|
|
97
|
+
}
|
|
98
|
+
/** The settings of an `.npmrc` text, by key, as written (comments and blank lines left out; the last one wins). */
|
|
99
|
+
export function npmrcSettings(text) {
|
|
100
|
+
const settings = {};
|
|
101
|
+
for (const line of text.split(/\r?\n/)) {
|
|
102
|
+
const match = /^\s*([^#;=\s][^=]*?)\s*=\s*(.*?)\s*$/.exec(line);
|
|
103
|
+
if (match)
|
|
104
|
+
settings[match[1].toLowerCase()] = match[2];
|
|
105
|
+
}
|
|
106
|
+
return settings;
|
|
107
|
+
}
|
|
108
|
+
/** The environment without npm's own `npm_config_*` variables, which npm exports to the scripts it runs. */
|
|
109
|
+
function withoutNpmConfig(env) {
|
|
110
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => !/^npm_config_/i.test(key)));
|
|
111
|
+
}
|
|
112
|
+
function readJson(path) {
|
|
113
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
114
|
+
}
|
|
115
|
+
function withTempDir(prefix, run) {
|
|
116
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
117
|
+
try {
|
|
118
|
+
return run(dir);
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
rmSync(dir, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** The floor script's verdict under an `npm` that answers `version`: admitted, refused (its own refusal), or a crash. */
|
|
125
|
+
function floorVerdict(script, version, env) {
|
|
126
|
+
return withTempDir('tk-npm-floor-', (dir) => {
|
|
127
|
+
// The version reaches the stub as data (an environment variable), never as shell text.
|
|
128
|
+
writeFileSync(join(dir, 'npm'), '#!/bin/sh\nprintf \'%s\\n\' "$TK_FAKE_NPM_VERSION"\n');
|
|
129
|
+
chmodSync(join(dir, 'npm'), 0o755);
|
|
130
|
+
const run = spawnSync(process.execPath, [script], {
|
|
131
|
+
env: { ...env, TK_FAKE_NPM_VERSION: version, PATH: `${dir}${delimiter}${env.PATH ?? ''}` },
|
|
132
|
+
encoding: 'utf8',
|
|
133
|
+
});
|
|
134
|
+
if (run.status === 0)
|
|
135
|
+
return 'admitted';
|
|
136
|
+
// A crash exits 1 too: only the script's own refusal counts as one.
|
|
137
|
+
if (run.status === 1 && /is older than/.test(run.stderr))
|
|
138
|
+
return 'refused';
|
|
139
|
+
return `it failed (${run.status ?? run.error?.message ?? 'no status'}): ${run.stderr.trim().split('\n').at(-1) ?? ''}`;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** Every check, over the app at `root`. See the module header and {@link InstallCheckOptions}. */
|
|
143
|
+
export function checkInstallHardening(root, options = {}) {
|
|
144
|
+
const problems = [];
|
|
145
|
+
const skipped = [];
|
|
146
|
+
const env = withoutNpmConfig(options.env ?? process.env);
|
|
147
|
+
const floor = options.npmFloor ?? '11.19.1';
|
|
148
|
+
const floorScript = options.floorScript === undefined ? 'scripts/check-npm-version.mjs' : options.floorScript;
|
|
149
|
+
const minReleaseAge = String(options.minReleaseAge ?? 1);
|
|
150
|
+
let manifest;
|
|
151
|
+
let lock;
|
|
152
|
+
try {
|
|
153
|
+
manifest = readJson(join(root, 'package.json'));
|
|
154
|
+
lock = readJson(join(root, 'package-lock.json'));
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
return { problems: [`package.json or package-lock.json could not be read: ${error.message}`], skipped, scripts: [] };
|
|
158
|
+
}
|
|
159
|
+
// 1. allowScripts against the lockfile.
|
|
160
|
+
const scripts = withInstallScripts(lock);
|
|
161
|
+
const allow = manifest.allowScripts;
|
|
162
|
+
const allowed = allow !== null && typeof allow === 'object' && !Array.isArray(allow) ? allow : {};
|
|
163
|
+
if (allow !== undefined && allowed !== allow)
|
|
164
|
+
problems.push('package.json allowScripts is not an object of package names');
|
|
165
|
+
const minScripts = options.minScripts ?? 1;
|
|
166
|
+
if (scripts.length < minScripts) {
|
|
167
|
+
problems.push(`the lockfile has ${scripts.length} packages with an install script, fewer than the ${minScripts} expected: it proves nothing about allowScripts`);
|
|
168
|
+
}
|
|
169
|
+
problems.push(...scriptProblems(lock, allowed));
|
|
170
|
+
if (options.run !== undefined) {
|
|
171
|
+
const runs = Object.entries(allowed)
|
|
172
|
+
.filter(([, value]) => value === true)
|
|
173
|
+
.map(([name]) => name)
|
|
174
|
+
.sort();
|
|
175
|
+
const expected = [...options.run].sort();
|
|
176
|
+
if (JSON.stringify(runs) !== JSON.stringify(expected)) {
|
|
177
|
+
problems.push(`allowScripts runs [${runs.join(', ')}], not the reviewed [${expected.join(', ')}]: every other install script is skipped (false)`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// 2. The pinned npx lines and local tools.
|
|
181
|
+
const devDependencies = (manifest.devDependencies ?? {});
|
|
182
|
+
for (const name of options.requireDevDependencies ?? []) {
|
|
183
|
+
if (!Object.hasOwn(devDependencies, name))
|
|
184
|
+
problems.push(`${name} is not a devDependency (an npx of a package the lockfile does not hold meets the install-script refusal)`);
|
|
185
|
+
}
|
|
186
|
+
const manifestScripts = (manifest.scripts ?? {});
|
|
187
|
+
for (const [name, text] of Object.entries(options.requireScripts ?? {})) {
|
|
188
|
+
if (manifestScripts[name] !== text)
|
|
189
|
+
problems.push(`package.json scripts.${name} is not ${JSON.stringify(text)}`);
|
|
190
|
+
}
|
|
191
|
+
// 3. .npmrc, as written.
|
|
192
|
+
let npmrc = null;
|
|
193
|
+
try {
|
|
194
|
+
npmrc = readFileSync(join(root, '.npmrc'), 'utf8');
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
problems.push('.npmrc could not be read: min-release-age and strict-allow-scripts are unset');
|
|
198
|
+
}
|
|
199
|
+
if (npmrc !== null) {
|
|
200
|
+
const settings = npmrcSettings(npmrc);
|
|
201
|
+
if (settings['min-release-age'] !== minReleaseAge)
|
|
202
|
+
problems.push(`.npmrc does not set min-release-age=${minReleaseAge}`);
|
|
203
|
+
if (settings['strict-allow-scripts'] !== 'true')
|
|
204
|
+
problems.push('.npmrc does not set strict-allow-scripts=true');
|
|
205
|
+
if (!options.allowLegacyPeerDeps && Object.hasOwn(settings, 'legacy-peer-deps')) {
|
|
206
|
+
problems.push('.npmrc sets legacy-peer-deps: a peer conflict is fixed by the versions, never by ignoring peers');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// 4. The lockfile's pins.
|
|
210
|
+
if (options.lockfilePins ?? true) {
|
|
211
|
+
const loose = unpinnedPackages(lock, options.registry);
|
|
212
|
+
if (loose.length > 0)
|
|
213
|
+
problems.push(`the lockfile does not pin by registry URL and sha512: ${loose.slice(0, 5).join(', ')}${loose.length > 5 ? ` and ${loose.length - 5} more` : ''}`);
|
|
214
|
+
}
|
|
215
|
+
// 5. npm is the package manager.
|
|
216
|
+
if (lock.lockfileVersion !== 3)
|
|
217
|
+
problems.push(`package-lock.json is lockfileVersion ${String(lock.lockfileVersion)}, not 3`);
|
|
218
|
+
if (manifest.pnpm !== undefined)
|
|
219
|
+
problems.push('package.json still has a pnpm key');
|
|
220
|
+
const git = spawnSync('git', ['ls-files'], { cwd: root, env, encoding: 'utf8' });
|
|
221
|
+
if (git.status !== 0) {
|
|
222
|
+
problems.push('git ls-files failed, so the tracked lockfiles are unknown');
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const tracked = git.stdout.split('\n').filter(Boolean);
|
|
226
|
+
const others = tracked.filter((file) => /(?:^|\/)(?:pnpm-lock\.yaml|pnpm-workspace\.yaml|yarn\.lock|bun\.lockb?|npm-shrinkwrap\.json)$/.test(file));
|
|
227
|
+
if (others.length > 0)
|
|
228
|
+
problems.push(`another package manager's file is tracked: ${others.join(', ')}`);
|
|
229
|
+
if (!tracked.includes('package-lock.json'))
|
|
230
|
+
problems.push('package-lock.json is not tracked');
|
|
231
|
+
}
|
|
232
|
+
// 6. The floor script, and the install command that runs it.
|
|
233
|
+
if (floorScript === null) {
|
|
234
|
+
skipped.push('the npm floor script: the app has none (floorScript: null)');
|
|
235
|
+
}
|
|
236
|
+
else if (!existsSync(join(root, floorScript))) {
|
|
237
|
+
problems.push(`${floorScript} does not exist: nothing refuses an npm too old to read allowScripts`);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
const cases = floorCases(floor, options.prereleaseBelowRelease ?? true);
|
|
241
|
+
for (const [versions, want] of [[cases.admitted, 'admitted'], [cases.refused, 'refused']]) {
|
|
242
|
+
for (const version of versions) {
|
|
243
|
+
const verdict = floorVerdict(join(root, floorScript), version, env);
|
|
244
|
+
if (verdict !== want) {
|
|
245
|
+
problems.push(`${floorScript} under npm ${version}: ${verdict === 'admitted' || verdict === 'refused' ? `${verdict}, not ${want}` : verdict}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const installCommand = options.installCommand === undefined ? (floorScript === null ? null : `node ${floorScript} && npm ci`) : options.installCommand;
|
|
251
|
+
if (installCommand !== null) {
|
|
252
|
+
let found;
|
|
253
|
+
try {
|
|
254
|
+
found = readJson(join(root, 'vercel.json')).installCommand;
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
found = undefined;
|
|
258
|
+
}
|
|
259
|
+
if (found !== installCommand)
|
|
260
|
+
problems.push(`vercel.json installCommand is not ${JSON.stringify(installCommand)}`);
|
|
261
|
+
}
|
|
262
|
+
// 7. packageManager.
|
|
263
|
+
const declared = /^npm@(\d+\.\d+\.\d+)(?:\+sha\d+\.[0-9a-f]+)?$/.exec(typeof manifest.packageManager === 'string' ? manifest.packageManager : '');
|
|
264
|
+
if (!declared) {
|
|
265
|
+
problems.push('package.json packageManager is not npm@X.Y.Z');
|
|
266
|
+
}
|
|
267
|
+
else if (!atLeast(declared[1], floor)) {
|
|
268
|
+
problems.push(`package.json packageManager names npm ${declared[1]}, below the floor ${floor}`);
|
|
269
|
+
}
|
|
270
|
+
const npmOnPath = spawnSync('npm', ['--version'], { cwd: root, env, encoding: 'utf8' });
|
|
271
|
+
const npmVersion = npmOnPath.status === 0 ? npmOnPath.stdout.trim() : null;
|
|
272
|
+
const mode = options.packageManager ?? 'path';
|
|
273
|
+
if (declared && mode !== 'declared') {
|
|
274
|
+
let nvmrc = null;
|
|
275
|
+
try {
|
|
276
|
+
nvmrc = readFileSync(join(root, '.nvmrc'), 'utf8').trim().replace(/^v/, '');
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
problems.push('.nvmrc could not be read, so packageManager cannot be held to its Node\'s npm');
|
|
280
|
+
}
|
|
281
|
+
const bundled = join(dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'package.json');
|
|
282
|
+
if (nvmrc !== null && process.version !== `v${nvmrc}`) {
|
|
283
|
+
skipped.push(`packageManager against the npm in use: this is Node ${process.version}, not .nvmrc's ${nvmrc}`);
|
|
284
|
+
}
|
|
285
|
+
else if (nvmrc !== null && mode === 'bundled' && !existsSync(bundled)) {
|
|
286
|
+
skipped.push('packageManager against the bundled npm: this Node ships none beside its binary');
|
|
287
|
+
}
|
|
288
|
+
else if (nvmrc !== null) {
|
|
289
|
+
const inUse = mode === 'bundled' ? readJson(bundled).version : npmVersion;
|
|
290
|
+
if (inUse !== declared[1]) {
|
|
291
|
+
problems.push(`package.json packageManager names npm ${declared[1]}, but ${mode === 'bundled' ? `Node ${nvmrc} ships` : 'the npm on PATH is'} ${inUse ?? 'unreadable'}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// 8. npm itself: it knows the two keys, takes them from the project, and refuses an unnamed script.
|
|
296
|
+
const probes = options.npmProbes ?? 'required';
|
|
297
|
+
if (probes === 'off') {
|
|
298
|
+
skipped.push('the npm checks (config keys, the strict-allow-scripts refusal): npmProbes is off');
|
|
299
|
+
}
|
|
300
|
+
else if (npmVersion === null || !atLeast(npmVersion, floor)) {
|
|
301
|
+
const why = `the npm on PATH is ${npmVersion ?? 'not runnable'}, below ${floor}, which ignores allowScripts and strict-allow-scripts`;
|
|
302
|
+
if (probes === 'required')
|
|
303
|
+
problems.push(`the npm checks did not run: ${why}`);
|
|
304
|
+
else
|
|
305
|
+
skipped.push(`the npm checks (config keys, the strict-allow-scripts refusal): ${why}`);
|
|
306
|
+
}
|
|
307
|
+
else if (npmrc !== null) {
|
|
308
|
+
problems.push(...npmProbeProblems(root, npmrc, env, minReleaseAge, options.allowLegacyPeerDeps === true));
|
|
309
|
+
}
|
|
310
|
+
return { problems, skipped, scripts };
|
|
311
|
+
}
|
|
312
|
+
/** What npm itself says about the project's `.npmrc`: asked with an empty user and global config, so only it answers. */
|
|
313
|
+
function npmProbeProblems(root, npmrc, env, minReleaseAge, allowLegacyPeerDeps) {
|
|
314
|
+
return withTempDir('tk-npm-config-', (dir) => {
|
|
315
|
+
const problems = [];
|
|
316
|
+
writeFileSync(join(dir, 'user'), '');
|
|
317
|
+
writeFileSync(join(dir, 'global'), '');
|
|
318
|
+
const projectOnly = { ...env, NPM_CONFIG_USERCONFIG: join(dir, 'user'), NPM_CONFIG_GLOBALCONFIG: join(dir, 'global') };
|
|
319
|
+
// `npm config get` echoes a key npm does not know (with only a warning), so the value is not read back: npm's own
|
|
320
|
+
// list prints each known key's default, commented out where a config file overrides it, and then the file's value.
|
|
321
|
+
let list = '';
|
|
322
|
+
try {
|
|
323
|
+
list = execFileSync('npm', ['config', 'ls', '-l'], { cwd: root, env: projectOnly, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
problems.push(`npm config ls -l failed: ${error.message.split('\n')[0]}`);
|
|
327
|
+
}
|
|
328
|
+
for (const [key, value] of [['min-release-age', minReleaseAge], ['strict-allow-scripts', 'true']]) {
|
|
329
|
+
const overridden = new RegExp(`^; ${key} = .* ; overridden by project$`, 'm');
|
|
330
|
+
const set = new RegExp(`^${key} = ${value}$`, 'm');
|
|
331
|
+
if (!overridden.test(list) || !set.test(list))
|
|
332
|
+
problems.push(`npm does not read ${key}=${value} from the project's .npmrc (an unknown key, or another config file's value)`);
|
|
333
|
+
}
|
|
334
|
+
if (!allowLegacyPeerDeps) {
|
|
335
|
+
const legacy = spawnSync('npm', ['config', 'get', 'legacy-peer-deps'], { cwd: root, env: projectOnly, encoding: 'utf8' });
|
|
336
|
+
if (legacy.stdout.trim() !== 'false')
|
|
337
|
+
problems.push('npm reads legacy-peer-deps as on for this project');
|
|
338
|
+
}
|
|
339
|
+
// The policy itself: a tree with a script allowScripts does not name is refused before anything runs.
|
|
340
|
+
const probe = join(dir, 'probe');
|
|
341
|
+
mkdirSync(join(probe, 'dep'), { recursive: true });
|
|
342
|
+
writeFileSync(join(probe, '.npmrc'), npmrc);
|
|
343
|
+
writeFileSync(join(probe, 'dep', 'package.json'), JSON.stringify({ name: 'dep', version: '1.0.0', scripts: { postinstall: "node -e \"require('fs').writeFileSync('../ran', '')\"" } }));
|
|
344
|
+
writeFileSync(join(probe, 'package.json'), JSON.stringify({ name: 'probe', version: '1.0.0', dependencies: { dep: 'file:./dep' } }));
|
|
345
|
+
const install = spawnSync('npm', ['install', '--offline', '--no-audit', '--no-fund'], { cwd: probe, env: projectOnly, encoding: 'utf8' });
|
|
346
|
+
if (install.status === 0 || !/ESTRICTALLOWSCRIPTS/.test(install.stderr)) {
|
|
347
|
+
problems.push("npm did not refuse (ESTRICTALLOWSCRIPTS) an install whose tree has a script allowScripts does not name, under the project's .npmrc");
|
|
348
|
+
}
|
|
349
|
+
if (existsSync(join(probe, 'ran')))
|
|
350
|
+
problems.push("npm ran an install script allowScripts does not name, under the project's .npmrc");
|
|
351
|
+
return problems;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
export const INSTALL_CHECK_USAGE = 'usage: tk-check-install-scripts [--root <dir>] [--run <package>]... [--run-none] [--min-scripts <n>] [--min-release-age <days>]\n' +
|
|
355
|
+
' [--npm-floor <X.Y.Z>] [--floor-script <path> | --no-floor-script] [--install-command <text> | --no-install-command]\n' +
|
|
356
|
+
' [--package-manager path|bundled|declared] [--npm-probes required|when-supported|off] [--require-dev <package>]...\n' +
|
|
357
|
+
' [--require-script <name>=<text>]... [--registry <url>] [--allow-legacy-peer-deps] [--no-lockfile-pins] [--no-prerelease-cases]';
|
|
358
|
+
const VALUE_FLAGS = [
|
|
359
|
+
'--root',
|
|
360
|
+
'--run',
|
|
361
|
+
'--min-scripts',
|
|
362
|
+
'--min-release-age',
|
|
363
|
+
'--npm-floor',
|
|
364
|
+
'--floor-script',
|
|
365
|
+
'--install-command',
|
|
366
|
+
'--package-manager',
|
|
367
|
+
'--npm-probes',
|
|
368
|
+
'--require-dev',
|
|
369
|
+
'--require-script',
|
|
370
|
+
'--registry',
|
|
371
|
+
];
|
|
372
|
+
const SWITCHES = ['--run-none', '--no-floor-script', '--no-install-command', '--allow-legacy-peer-deps', '--no-lockfile-pins', '--no-prerelease-cases'];
|
|
373
|
+
/** The bin's arguments as a root and options. Throws, with the usage, on an unknown flag or a value that is not one. */
|
|
374
|
+
export function parseInstallCheckArgs(args, cwd) {
|
|
375
|
+
const values = new Map();
|
|
376
|
+
const switches = new Set();
|
|
377
|
+
const fail = (message) => {
|
|
378
|
+
throw new Error(`${message}\n${INSTALL_CHECK_USAGE}`);
|
|
379
|
+
};
|
|
380
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
381
|
+
const arg = args[i];
|
|
382
|
+
if (SWITCHES.includes(arg)) {
|
|
383
|
+
switches.add(arg);
|
|
384
|
+
}
|
|
385
|
+
else if (VALUE_FLAGS.includes(arg)) {
|
|
386
|
+
const value = args[i + 1];
|
|
387
|
+
if (value === undefined)
|
|
388
|
+
fail(`${arg} needs a value`);
|
|
389
|
+
values.set(arg, [...(values.get(arg) ?? []), value]);
|
|
390
|
+
i += 1;
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
fail(`unknown argument ${arg}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const last = (flag) => values.get(flag)?.at(-1);
|
|
397
|
+
const count = (flag) => {
|
|
398
|
+
const text = last(flag);
|
|
399
|
+
if (text === undefined)
|
|
400
|
+
return undefined;
|
|
401
|
+
if (!/^\d+$/.test(text))
|
|
402
|
+
fail(`${flag} takes a whole number`);
|
|
403
|
+
return Number(text);
|
|
404
|
+
};
|
|
405
|
+
const oneOf = (flag, choices) => {
|
|
406
|
+
const text = last(flag);
|
|
407
|
+
if (text !== undefined && !choices.includes(text))
|
|
408
|
+
fail(`${flag} takes one of ${choices.join(', ')}`);
|
|
409
|
+
return text;
|
|
410
|
+
};
|
|
411
|
+
if (switches.has('--run-none') && values.has('--run'))
|
|
412
|
+
fail('--run-none and --run contradict each other');
|
|
413
|
+
if (switches.has('--no-floor-script') && values.has('--floor-script'))
|
|
414
|
+
fail('--no-floor-script and --floor-script contradict each other');
|
|
415
|
+
if (switches.has('--no-install-command') && values.has('--install-command'))
|
|
416
|
+
fail('--no-install-command and --install-command contradict each other');
|
|
417
|
+
const requireScripts = {};
|
|
418
|
+
for (const pair of values.get('--require-script') ?? []) {
|
|
419
|
+
const at = pair.indexOf('=');
|
|
420
|
+
if (at < 1)
|
|
421
|
+
fail('--require-script takes <name>=<text>');
|
|
422
|
+
requireScripts[pair.slice(0, at)] = pair.slice(at + 1);
|
|
423
|
+
}
|
|
424
|
+
const floor = last('--npm-floor');
|
|
425
|
+
if (floor !== undefined && !releaseVersion(floor))
|
|
426
|
+
fail('--npm-floor takes an X.Y.Z version');
|
|
427
|
+
const options = {
|
|
428
|
+
...(switches.has('--run-none') ? { run: [] } : values.has('--run') ? { run: values.get('--run') } : {}),
|
|
429
|
+
...(count('--min-scripts') !== undefined ? { minScripts: count('--min-scripts') } : {}),
|
|
430
|
+
...(count('--min-release-age') !== undefined ? { minReleaseAge: count('--min-release-age') } : {}),
|
|
431
|
+
...(floor !== undefined ? { npmFloor: floor } : {}),
|
|
432
|
+
...(switches.has('--no-floor-script') ? { floorScript: null } : last('--floor-script') !== undefined ? { floorScript: last('--floor-script') } : {}),
|
|
433
|
+
...(switches.has('--no-install-command') ? { installCommand: null } : last('--install-command') !== undefined ? { installCommand: last('--install-command') } : {}),
|
|
434
|
+
...(oneOf('--package-manager', ['path', 'bundled', 'declared']) !== undefined ? { packageManager: oneOf('--package-manager', ['path', 'bundled', 'declared']) } : {}),
|
|
435
|
+
...(oneOf('--npm-probes', ['required', 'when-supported', 'off']) !== undefined ? { npmProbes: oneOf('--npm-probes', ['required', 'when-supported', 'off']) } : {}),
|
|
436
|
+
...(values.has('--require-dev') ? { requireDevDependencies: values.get('--require-dev') } : {}),
|
|
437
|
+
...(values.has('--require-script') ? { requireScripts } : {}),
|
|
438
|
+
...(last('--registry') !== undefined ? { registry: last('--registry') } : {}),
|
|
439
|
+
...(switches.has('--allow-legacy-peer-deps') ? { allowLegacyPeerDeps: true } : {}),
|
|
440
|
+
...(switches.has('--no-lockfile-pins') ? { lockfilePins: false } : {}),
|
|
441
|
+
...(switches.has('--no-prerelease-cases') ? { prereleaseBelowRelease: false } : {}),
|
|
442
|
+
};
|
|
443
|
+
const root = last('--root');
|
|
444
|
+
return { root: root === undefined ? cwd : resolve(cwd, root), options };
|
|
445
|
+
}
|
|
446
|
+
/** The whole bin, as a function. */
|
|
447
|
+
export function runInstallCheck(args, cwd, env = process.env) {
|
|
448
|
+
try {
|
|
449
|
+
const { root, options } = parseInstallCheckArgs(args, cwd);
|
|
450
|
+
const result = checkInstallHardening(root, { ...options, env });
|
|
451
|
+
const skipped = result.skipped.map((line) => ` not checked: ${line}`);
|
|
452
|
+
if (result.problems.length > 0) {
|
|
453
|
+
return { code: 1, lines: ['tk-check-install-scripts: the install hardening does not hold:', ...result.problems.map((p) => ` ${p}`), ...skipped] };
|
|
454
|
+
}
|
|
455
|
+
return { code: 0, lines: [`ok install hardening: ${result.scripts.length} install scripts reviewed in allowScripts, .npmrc, the lockfile and the npm floor hold`, ...skipped] };
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
return { code: 1, lines: [`tk-check-install-scripts: ${error.message}`] };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# `@catalystiq/test-kit/neon-endpoint`
|
|
2
|
+
|
|
3
|
+
Which database a connection string addresses, read the way the clients that will use it read it. One dependency-free
|
|
4
|
+
module (no import, not even a `node:` builtin; it spells no database host), so an app can import it in a build script
|
|
5
|
+
run with bare `node` and bundle it into server code at boot. `tk-dev-guard` and the Vitest setup's production check
|
|
6
|
+
do not read through it: they keep their own host reading (`src/setup/database.ts`), which normalises a whole value
|
|
7
|
+
and refuses more; `test/env.test.ts` holds both to the same list of production spellings.
|
|
8
|
+
|
|
9
|
+
An app that imports it outside its tests lists `@catalystiq/test-kit` under `dependencies`, not `devDependencies`.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
A `postgres:` URL keeps an opaque host: `new URL(url).hostname` neither percent-decodes nor IDNA-maps it. The clients
|
|
14
|
+
do. `pg-connection-string` decodes the host, and the Neon serverless driver's `neon()` re-parses the string as `http:` +
|
|
15
|
+
the rest, so its host is a WHATWG special-scheme host: a fullwidth letter or hyphen becomes its ASCII twin, a soft
|
|
16
|
+
hyphen disappears, an ideographic full stop becomes a dot. A guard that matches the text as written passes a production
|
|
17
|
+
endpoint id spelled that way, and the app connects to production through it.
|
|
18
|
+
|
|
19
|
+
## An app's guard
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { createEndpointGuard } from '@catalystiq/test-kit/neon-endpoint';
|
|
23
|
+
|
|
24
|
+
export const db = createEndpointGuard({
|
|
25
|
+
production: ['ep-…'], // bare ids: no -pooler, no domain
|
|
26
|
+
allowed: ['ep-…'], // what a preview deployment may connect to
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
db.namesProduction(value); // the denylist: every non-production context
|
|
30
|
+
db.productionIn(value); // the production id it names, or null: for a message
|
|
31
|
+
db.isAllowed(url); // the allowlist: exactly one allowed endpoint, production nowhere
|
|
32
|
+
db.endpointId(url); // the one endpoint a URL connects to, or null
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
An id that is not a bare `ep-…` id, an empty production list, or an id on both lists throws at load.
|
|
36
|
+
|
|
37
|
+
| Question | Reads | Errs toward |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `namesProduction`, `productionIn`, `listedEntryIn(value, entries)` | Every shape, on any domain: a URL (one host or a comma-separated list), a keyword/value string, a bare `PGHOST`, `PGOPTIONS endpoint=…`, a `?host=`, an `options=endpoint%3D…`, each as written, percent-encoded (up to three times over) or spelled with characters a URL host maps to ASCII. An entry is an endpoint id or a host (its subdomains match). | Naming: a value that might reach production is production. |
|
|
40
|
+
| `isAllowed`, `endpointId`, `neonEndpointId(url)` | A URL whose host is under Neon's own domains, read both as `pg` and as `neon()` read it, with every other route it carries (`?host=`, `options=endpoint=` / `project=`, the password field, `?password=`). | Null: a percent-encoded, mapped, trailing-dot or comma-separated host, a `hostaddr`, a `?host=` elsewhere, or a second endpoint anywhere in it is no single endpoint, which an allowlist refuses. |
|
|
41
|
+
|
|
42
|
+
Also exported: `neonEndpointIds(url)` (every endpoint a URL names), `namesEndpoint(url, ids)`,
|
|
43
|
+
`hasUnattributableHostOverride(url)`, `hostSpellings(host)`, `isListedHost(host, entries)`, `endpointOf(host)`. The URL
|
|
44
|
+
readers take `{ anyDomain: true }` to read an `ep-…` label on any domain (a test corpus, a proxy domain). Nothing
|
|
45
|
+
returns or throws a piece of the value other than an endpoint id or a list entry.
|
|
46
|
+
|
|
47
|
+
## Replacing each app's own matcher
|
|
48
|
+
|
|
49
|
+
Each app keeps its ids, its rule for which context is production or a preview, and its messages. What its matcher
|
|
50
|
+
becomes, and what its copy does that this one does not:
|
|
51
|
+
|
|
52
|
+
| App | Its matcher | Becomes | Not here |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| divorce-virtual | `lib/db/neon-endpoint.mjs`: `namesProductionEndpoint`, `isPreviewEndpoint`, `neonEndpointId`, `neonEndpointIds`, `hasUnattributableHostOverride` | `db.namesProduction`, `db.isAllowed`, and the same three by name | Nothing is lost; one answer changes. `namesProductionEndpoint` reads a host only under Neon's domains; `db.namesProduction` also names the id on any other domain and in a value that is not a URL (`namesEndpoint(url, [id])` is the old reading). `isPreviewDeployment`, `PG_ROUTING_ENV_VARS` and the id constants stay in the app. |
|
|
55
|
+
| easy-carnet | `src/lib/db/preview-database-guard.ts`: `neonEndpointIdOf`, `namesProductionAnywhere`, `endpointOptionsOf` | `db.endpointId`, `db.namesProduction`, and `db.isAllowed` for the endpoint and its options together | `neonEndpointIdOf` answers for a percent-encoded host, a host with a final dot and a host on any domain; `db.endpointId` answers null for each (a preview is then refused, and `productionEndpointPinDrift` would not name a drift on such a string). It refuses every location key in the query (`port`, `dbname`, `database`); this one refuses only `hostaddr` and a `?host=` that names another place. Its production match is a substring; this one matches a whole id. `onVercel`, the `VERCEL_ENV` rules, the pin-drift line and `withExplicitVerifyFullSslMode` stay in the app. |
|
|
56
|
+
| easy-plans | `lib/db/production-endpoint.ts`: `endpointOf`, `routedEndpoint` | `neonEndpointId` (null whenever a route in the URL names another endpoint) | `endpointOf` reads any domain (`{ anyDomain: true }`). `routedEndpoint` refuses more than a disagreement: any `options` endpoint, any `host` or `password` parameter, a second `options`, a URL with no password (pg then reads `PGPASSWORD` or a pgpass file) and an endpoint in `PGOPTIONS`. Nothing here reads the environment or a pgpass file: an app that keeps that rule keeps `routedEndpoint`. `withVerifiedSsl` stays in the app. |
|
|
57
|
+
| easy-passport | `scripts/assert-not-prod.mjs`, `lib/db/assert-not-prod.ts`: `url.includes(host)` | `listedEntryIn(url, hosts)` over the same `db/production-endpoints.json` list | A substring match also takes part of an id as an entry; this one needs the whole id or a host. The `ALLOW_PROD_DB`, `VERCEL_ENV` and `NODE_ENV` exemptions, the strict form and the messages stay in the app. |
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which database a connection string addresses, read from the string alone the way the clients that will use it read
|
|
3
|
+
* it: no network, no query. The reader for an app's build and boot checks, fed with the app's own endpoint ids
|
|
4
|
+
* ({@link createEndpointGuard}). The kit's own guards (`tk-dev-guard`, the Vitest setup's production check) keep their
|
|
5
|
+
* own reading, `../setup/database.ts`, which normalises a whole value and errs further towards refusing.
|
|
6
|
+
*
|
|
7
|
+
* WHY "THE WAY THE CLIENTS READ IT". A `postgres:` URL keeps an opaque host: `new URL(url).hostname` neither
|
|
8
|
+
* percent-decodes nor IDNA-maps it. The clients do. `pg-connection-string` runs `decodeURIComponent` on the host, and
|
|
9
|
+
* the Neon serverless driver's `neon()` re-parses the whole string as `http:` + the rest, so its host is read as a
|
|
10
|
+
* WHATWG special-scheme host: percent-decoded, lowercased and IDNA-mapped. A fullwidth letter or hyphen becomes its
|
|
11
|
+
* ASCII twin, a soft hyphen disappears, an ideographic full stop becomes a dot. A guard that matches the text as
|
|
12
|
+
* written passes a production endpoint id spelled with any of those, and the app then connects to production through
|
|
13
|
+
* it. Every reading here is therefore taken over each spelling a client might resolve ({@link hostSpellings}).
|
|
14
|
+
*
|
|
15
|
+
* TWO QUESTIONS, TWO READERS.
|
|
16
|
+
* - "Does this value name one of these databases ANYWHERE?" ({@link listedEntryIn}, {@link namesEndpoint}): a
|
|
17
|
+
* denylist, so it reads every shape (a URL, a keyword/value string, a bare `PGHOST`, `PGOPTIONS endpoint=…`, a
|
|
18
|
+
* `?host=` override, an `options=endpoint%3D…`) on any domain, and errs on naming.
|
|
19
|
+
* - "Which ONE endpoint does this URL connect to?" ({@link neonEndpointId}): an allowlist's question, so it answers
|
|
20
|
+
* only for a host under Neon's own domains whose every reading and every other route in the URL agree, and null
|
|
21
|
+
* otherwise, which an allowlist refuses.
|
|
22
|
+
*
|
|
23
|
+
* A LEAF ON PURPOSE: no import, not even a `node:` builtin, so an app can bundle it into server code and a build script
|
|
24
|
+
* can run it with bare `node`. It spells no database host (an app's build may refuse one in a server chunk); ids are
|
|
25
|
+
* the caller's. Nothing here returns or throws a piece of the value other than an endpoint id or a list entry, which
|
|
26
|
+
* are routing names and not credentials.
|
|
27
|
+
*/
|
|
28
|
+
export interface EndpointReadOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Read an `ep-…` first label as an endpoint on any domain, not only under Neon's own. For a test corpus or a proxy
|
|
31
|
+
* domain; an allowlist on real strings leaves it off, since `ep-<allowed>.elsewhere.example` is not that endpoint.
|
|
32
|
+
*/
|
|
33
|
+
anyDomain?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** An `ep-…` label with any `-pooler` suffix stripped, or null. The one normalisation every URL reading uses. */
|
|
36
|
+
export declare function endpointFromLabel(label: string): string | null;
|
|
37
|
+
/**
|
|
38
|
+
* A host as a WHATWG `http:`/`https:` URL reads it: percent-decoded, IDNA-mapped, lowercased. That is the host `neon()`
|
|
39
|
+
* fetches from. Null when the text does not parse as a host at all.
|
|
40
|
+
*/
|
|
41
|
+
export declare function specialSchemeHostname(host: string): string | null;
|
|
42
|
+
/** The URL's host when the whole string is re-read under a special scheme, exactly as `neon()` does, or null. */
|
|
43
|
+
export declare function specialSchemeUrlHostname(url: string): string | null;
|
|
44
|
+
/**
|
|
45
|
+
* Every spelling of a host some client might resolve: as written, percent-decoded (`pg-connection-string`), and each of
|
|
46
|
+
* those as a special-scheme URL reads it (`neon()`).
|
|
47
|
+
*/
|
|
48
|
+
export declare function hostSpellings(host: string): string[];
|
|
49
|
+
/**
|
|
50
|
+
* True when the URL carries a host override (`hostaddr`, or a non-empty `host` parameter) and cannot be pinned to one
|
|
51
|
+
* endpoint with it. An allowlist cannot show such a URL is not production's, so it must refuse it.
|
|
52
|
+
*/
|
|
53
|
+
export declare function hasUnattributableHostOverride(url: unknown, options?: EndpointReadOptions): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* The ONE endpoint id the URL connects to, or null: the host is not an endpoint host under Neon's domains (a
|
|
56
|
+
* percent-encoded, IDNA-mapped, trailing-dot or comma-separated host included, since the opaque and the special-scheme
|
|
57
|
+
* reading must agree), the value is not a URL, or the URL cannot be pinned to one endpoint (a `hostaddr`, a `host`
|
|
58
|
+
* entry elsewhere, or an `options` or password route naming another endpoint than the host).
|
|
59
|
+
*/
|
|
60
|
+
export declare function neonEndpointId(url: unknown, options?: EndpointReadOptions): string | null;
|
|
61
|
+
/** A value as written and percent-decoded, up to `rounds` times over (a client may decode what another already did). */
|
|
62
|
+
export declare function decodedForms(value: string, rounds?: number): string[];
|
|
63
|
+
/**
|
|
64
|
+
* Every endpoint id the URL names, wherever it names one: the URL host (each member of a comma-separated list, each
|
|
65
|
+
* spelling), each `host` query parameter entry, an `endpoint=<id>` (or the older `project=<id>`, the id quoted or not)
|
|
66
|
+
* in an `options` parameter, the same in the password field and in a `password` parameter (which node-postgres uses
|
|
67
|
+
* INSTEAD of the URL's password). Ids are normalised as the host's are (`-pooler` stripped).
|
|
68
|
+
*/
|
|
69
|
+
export declare function neonEndpointIds(url: unknown, options?: EndpointReadOptions): string[];
|
|
70
|
+
/** True when ANY endpoint the URL names ({@link neonEndpointIds}) is one of `ids`. */
|
|
71
|
+
export declare function namesEndpoint(url: unknown, ids: readonly string[], options?: EndpointReadOptions): boolean;
|
|
72
|
+
/** The endpoint id of a host whose first label is one (`ep-quiet-sun-123-pooler.region.example` → `ep-quiet-sun-123`). */
|
|
73
|
+
export declare function endpointOf(host: string): string | null;
|
|
74
|
+
/**
|
|
75
|
+
* Whether `host` is on a list of hosts and endpoint ids: an entry matches the host itself, any subdomain of it, and,
|
|
76
|
+
* for a host whose first label is an endpoint id, that endpoint with or without `-pooler`, on any domain.
|
|
77
|
+
*/
|
|
78
|
+
export declare function isListedHost(host: string, entries: readonly string[]): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* The entry of a list of hosts and endpoint ids that a value names, or null. One pass over every host-shaped token of
|
|
81
|
+
* every text a client might resolve from the value, so every connection-string shape counts: a URL (one host or
|
|
82
|
+
* several), a keyword/value string (`host=… dbname=…`), `PGHOST` (with a port), `PGOPTIONS endpoint=…`, a URL's
|
|
83
|
+
* `options=endpoint%3D…` or `?host=`, each as written, percent-encoded, or spelled with characters a URL host maps back
|
|
84
|
+
* to ASCII (fullwidth letters and hyphens, a soft hyphen, an ideographic full stop).
|
|
85
|
+
*/
|
|
86
|
+
export declare function listedEntryIn(value: string, entries: readonly string[]): string | null;
|
|
87
|
+
export interface EndpointGuardConfig {
|
|
88
|
+
/** Production's compute endpoint ids (`ep-…`, bare: no `-pooler`, no domain). At least one. */
|
|
89
|
+
production: readonly string[];
|
|
90
|
+
/** The endpoint ids an allowlisted context (a preview deployment) may connect to. */
|
|
91
|
+
allowed?: readonly string[];
|
|
92
|
+
/** See {@link EndpointReadOptions.anyDomain}; applies to {@link EndpointGuard.endpointId} and `isAllowed`. */
|
|
93
|
+
anyDomain?: boolean;
|
|
94
|
+
}
|
|
95
|
+
export interface EndpointGuard {
|
|
96
|
+
readonly production: readonly string[];
|
|
97
|
+
readonly allowed: readonly string[];
|
|
98
|
+
/** The ONE endpoint a URL connects to, or null ({@link neonEndpointId}). */
|
|
99
|
+
endpointId(url: unknown): string | null;
|
|
100
|
+
/** The production id a value names anywhere, in any shape and on any domain, or null. Never part of the value. */
|
|
101
|
+
productionIn(value: unknown): string | null;
|
|
102
|
+
/** True when the value names a production endpoint anywhere: the denylist every non-production context applies. */
|
|
103
|
+
namesProduction(value: unknown): boolean;
|
|
104
|
+
/** True when the URL pins exactly one allowed endpoint and names production nowhere: the allowlist. */
|
|
105
|
+
isAllowed(url: unknown): boolean;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* An app's guard, from its own ids: `const db = createEndpointGuard({ production: ['ep-…'], allowed: ['ep-…'] })`,
|
|
109
|
+
* then `db.namesProduction(url)` wherever production must be refused and `db.isAllowed(url)` where only the allowed
|
|
110
|
+
* endpoints may be used. An id that is not a bare `ep-…` id throws here, at load: a mistyped list guards nothing.
|
|
111
|
+
*/
|
|
112
|
+
export declare function createEndpointGuard(config: EndpointGuardConfig): EndpointGuard;
|