@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,359 @@
|
|
|
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
|
+
const ENDPOINT_LABEL = /^(ep-[a-z0-9]+(?:-[a-z0-9]+)*?)(?:-pooler)?$/;
|
|
29
|
+
const NEON_DOMAIN = /(?:^|\.)neon\.(?:tech|build)$/;
|
|
30
|
+
/** An `ep-…` label with any `-pooler` suffix stripped, or null. The one normalisation every URL reading uses. */
|
|
31
|
+
export function endpointFromLabel(label) {
|
|
32
|
+
const match = ENDPOINT_LABEL.exec(String(label).toLowerCase());
|
|
33
|
+
return match ? match[1] : null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The endpoint id of one bare host name, or null when it is not an endpoint host. A host holding a comma is null too:
|
|
37
|
+
* libpq reads `ep-a…,ep-b…` as a list of hosts, and a non-special scheme keeps the comma in `URL.hostname`, so reading
|
|
38
|
+
* its first label would silently drop every later member.
|
|
39
|
+
*/
|
|
40
|
+
function endpointFromHost(host, anyDomain) {
|
|
41
|
+
const h = String(host).trim().toLowerCase();
|
|
42
|
+
if (h.includes(','))
|
|
43
|
+
return null;
|
|
44
|
+
if (!anyDomain && !NEON_DOMAIN.test(h))
|
|
45
|
+
return null;
|
|
46
|
+
return endpointFromLabel(h.split('.')[0] ?? '');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A host as a WHATWG `http:`/`https:` URL reads it: percent-decoded, IDNA-mapped, lowercased. That is the host `neon()`
|
|
50
|
+
* fetches from. Null when the text does not parse as a host at all.
|
|
51
|
+
*/
|
|
52
|
+
export function specialSchemeHostname(host) {
|
|
53
|
+
try {
|
|
54
|
+
return new URL(`https://${host}`).hostname || null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** The URL's host when the whole string is re-read under a special scheme, exactly as `neon()` does, or null. */
|
|
61
|
+
export function specialSchemeUrlHostname(url) {
|
|
62
|
+
try {
|
|
63
|
+
return new URL(String(url).replace(/^[a-z][a-z0-9+.-]*:/i, 'https:')).hostname || null;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Every spelling of a host some client might resolve: as written, percent-decoded (`pg-connection-string`), and each of
|
|
71
|
+
* those as a special-scheme URL reads it (`neon()`).
|
|
72
|
+
*/
|
|
73
|
+
export function hostSpellings(host) {
|
|
74
|
+
const raw = String(host);
|
|
75
|
+
const out = [raw];
|
|
76
|
+
try {
|
|
77
|
+
out.push(decodeURIComponent(raw));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// A malformed escape is read as written.
|
|
81
|
+
}
|
|
82
|
+
for (const h of [...out]) {
|
|
83
|
+
const special = specialSchemeHostname(h);
|
|
84
|
+
if (special)
|
|
85
|
+
out.push(special);
|
|
86
|
+
}
|
|
87
|
+
return [...new Set(out)];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The endpoint ids of a host as SOME client might read it, for the "names it anywhere" list only: every spelling, each
|
|
91
|
+
* split on commas (a libpq host list, each member spelled again) and with trailing dots stripped (a fully qualified
|
|
92
|
+
* name resolves to the same compute).
|
|
93
|
+
*/
|
|
94
|
+
function endpointsFromHostLenient(host, anyDomain) {
|
|
95
|
+
const ids = [];
|
|
96
|
+
for (const spelling of hostSpellings(host)) {
|
|
97
|
+
for (const member of spelling.split(',')) {
|
|
98
|
+
for (const m of hostSpellings(member.trim()))
|
|
99
|
+
ids.push(endpointFromHost(m.trim().replace(/\.+$/, ''), anyDomain));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return ids;
|
|
103
|
+
}
|
|
104
|
+
function parseUrl(url) {
|
|
105
|
+
if (typeof url !== 'string')
|
|
106
|
+
return null;
|
|
107
|
+
try {
|
|
108
|
+
return new URL(url);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** Every query parameter named `name`, in any letter case: reading every spelling only ever names more endpoints. */
|
|
115
|
+
function paramValues(parsed, name) {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const [key, value] of parsed.searchParams)
|
|
118
|
+
if (key.toLowerCase() === name)
|
|
119
|
+
out.push(value);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The host overrides node-postgres honours: a non-empty `?host=` REPLACES the URL host for `pg` (each entry of a
|
|
124
|
+
* comma-separated list), while `neon()` ignores it and fetches from the URL host. `hostaddr` is an IP address nothing
|
|
125
|
+
* here can attribute to an endpoint.
|
|
126
|
+
*/
|
|
127
|
+
function hostOverrides(parsed) {
|
|
128
|
+
const hosts = paramValues(parsed, 'host')
|
|
129
|
+
.flatMap((v) => v.split(','))
|
|
130
|
+
.map((h) => h.trim())
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
return { hosts, hostaddr: paramValues(parsed, 'hostaddr').length > 0 };
|
|
133
|
+
}
|
|
134
|
+
/** The endpoint the URL host names, read BOTH ways a client reads it; both must be an endpoint host for one endpoint. */
|
|
135
|
+
function urlHostEndpointId(parsed, url, anyDomain) {
|
|
136
|
+
const opaque = endpointFromHost(parsed.hostname, anyDomain);
|
|
137
|
+
if (opaque === null)
|
|
138
|
+
return null;
|
|
139
|
+
const special = specialSchemeUrlHostname(url);
|
|
140
|
+
return special !== null && endpointFromHost(special, anyDomain) === opaque ? opaque : null;
|
|
141
|
+
}
|
|
142
|
+
/** {@link neonEndpointId} before the cross-check against every other route the URL carries. */
|
|
143
|
+
function connectEndpointId(url, anyDomain) {
|
|
144
|
+
const parsed = parseUrl(url);
|
|
145
|
+
if (!parsed)
|
|
146
|
+
return null;
|
|
147
|
+
const { hosts, hostaddr } = hostOverrides(parsed);
|
|
148
|
+
if (hostaddr)
|
|
149
|
+
return null;
|
|
150
|
+
const fromUrlHost = urlHostEndpointId(parsed, url, anyDomain);
|
|
151
|
+
if (hosts.length === 0 || fromUrlHost === null)
|
|
152
|
+
return fromUrlHost;
|
|
153
|
+
// `pg` connects to the override, `neon()` to the URL host, so the URL host must itself be an endpoint host (above)
|
|
154
|
+
// and every override must name the same endpoint.
|
|
155
|
+
return hosts.every((h) => endpointFromHost(h, anyDomain) === fromUrlHost) ? fromUrlHost : null;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* True when the URL carries a host override (`hostaddr`, or a non-empty `host` parameter) and cannot be pinned to one
|
|
159
|
+
* endpoint with it. An allowlist cannot show such a URL is not production's, so it must refuse it.
|
|
160
|
+
*/
|
|
161
|
+
export function hasUnattributableHostOverride(url, options = {}) {
|
|
162
|
+
const parsed = parseUrl(url);
|
|
163
|
+
if (!parsed)
|
|
164
|
+
return false;
|
|
165
|
+
const { hosts, hostaddr } = hostOverrides(parsed);
|
|
166
|
+
if (!hostaddr && hosts.length === 0)
|
|
167
|
+
return false;
|
|
168
|
+
return connectEndpointId(url, options.anyDomain === true) === null;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The ONE endpoint id the URL connects to, or null: the host is not an endpoint host under Neon's domains (a
|
|
172
|
+
* percent-encoded, IDNA-mapped, trailing-dot or comma-separated host included, since the opaque and the special-scheme
|
|
173
|
+
* reading must agree), the value is not a URL, or the URL cannot be pinned to one endpoint (a `hostaddr`, a `host`
|
|
174
|
+
* entry elsewhere, or an `options` or password route naming another endpoint than the host).
|
|
175
|
+
*/
|
|
176
|
+
export function neonEndpointId(url, options = {}) {
|
|
177
|
+
const id = connectEndpointId(url, options.anyDomain === true);
|
|
178
|
+
if (id === null)
|
|
179
|
+
return null;
|
|
180
|
+
return neonEndpointIds(url, options).every((other) => other === id) ? id : null;
|
|
181
|
+
}
|
|
182
|
+
/** A value as written and percent-decoded, up to `rounds` times over (a client may decode what another already did). */
|
|
183
|
+
export function decodedForms(value, rounds = 3) {
|
|
184
|
+
const forms = [value];
|
|
185
|
+
let text = value;
|
|
186
|
+
for (let i = 0; i < rounds; i += 1) {
|
|
187
|
+
const next = decodedOnce(text);
|
|
188
|
+
if (next === text)
|
|
189
|
+
break;
|
|
190
|
+
forms.push(next);
|
|
191
|
+
text = next;
|
|
192
|
+
}
|
|
193
|
+
return forms;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Each run of `%XX` escapes decoded as UTF-8 on its own, so one malformed `%` elsewhere cannot hide the rest, and an
|
|
197
|
+
* escaped non-ASCII letter (`%EF%BD%85`) becomes the letter a client would then map.
|
|
198
|
+
*/
|
|
199
|
+
function decodedOnce(value) {
|
|
200
|
+
return value.replace(/(?:%[0-9a-f]{2})+/gi, (run) => utf8(run));
|
|
201
|
+
}
|
|
202
|
+
function utf8(run) {
|
|
203
|
+
const bytes = new Uint8Array(run.length / 3);
|
|
204
|
+
for (let i = 0; i < bytes.length; i += 1)
|
|
205
|
+
bytes[i] = Number.parseInt(run.slice(i * 3 + 1, i * 3 + 3), 16);
|
|
206
|
+
return new TextDecoder().decode(bytes);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Every endpoint id the URL names, wherever it names one: the URL host (each member of a comma-separated list, each
|
|
210
|
+
* spelling), each `host` query parameter entry, an `endpoint=<id>` (or the older `project=<id>`, the id quoted or not)
|
|
211
|
+
* in an `options` parameter, the same in the password field and in a `password` parameter (which node-postgres uses
|
|
212
|
+
* INSTEAD of the URL's password). Ids are normalised as the host's are (`-pooler` stripped).
|
|
213
|
+
*/
|
|
214
|
+
export function neonEndpointIds(url, options = {}) {
|
|
215
|
+
const parsed = parseUrl(url);
|
|
216
|
+
if (!parsed)
|
|
217
|
+
return [];
|
|
218
|
+
const anyDomain = options.anyDomain === true;
|
|
219
|
+
const ids = [];
|
|
220
|
+
const add = (id) => {
|
|
221
|
+
if (id && !ids.includes(id))
|
|
222
|
+
ids.push(id);
|
|
223
|
+
};
|
|
224
|
+
for (const id of endpointsFromHostLenient(parsed.hostname, anyDomain))
|
|
225
|
+
add(id);
|
|
226
|
+
// The whole string re-read under a special scheme, as `neon()` reads it (backslashes, IDNA).
|
|
227
|
+
const special = specialSchemeUrlHostname(url);
|
|
228
|
+
if (special)
|
|
229
|
+
for (const id of endpointsFromHostLenient(special, anyDomain))
|
|
230
|
+
add(id);
|
|
231
|
+
for (const h of hostOverrides(parsed).hosts)
|
|
232
|
+
for (const id of endpointsFromHostLenient(h, anyDomain))
|
|
233
|
+
add(id);
|
|
234
|
+
const named = /(?:^|[^a-z0-9_])(?:endpoint|project)\s*[=:]\s*["']?(ep-[a-z0-9-]+)["']?/gi;
|
|
235
|
+
const routes = [...paramValues(parsed, 'options'), parsed.password, ...paramValues(parsed, 'password')];
|
|
236
|
+
for (const route of routes) {
|
|
237
|
+
for (const form of decodedForms(route)) {
|
|
238
|
+
for (const m of form.matchAll(named))
|
|
239
|
+
add(endpointFromLabel(m[1]));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return ids;
|
|
243
|
+
}
|
|
244
|
+
/** True when ANY endpoint the URL names ({@link neonEndpointIds}) is one of `ids`. */
|
|
245
|
+
export function namesEndpoint(url, ids, options = {}) {
|
|
246
|
+
const wanted = ids.map((id) => endpointFromLabel(id.trim()) ?? id.trim().toLowerCase());
|
|
247
|
+
return neonEndpointIds(url, options).some((id) => wanted.includes(id));
|
|
248
|
+
}
|
|
249
|
+
/** The endpoint id of a host whose first label is one (`ep-quiet-sun-123-pooler.region.example` → `ep-quiet-sun-123`). */
|
|
250
|
+
export function endpointOf(host) {
|
|
251
|
+
const first = host.toLowerCase().split('.')[0] ?? '';
|
|
252
|
+
if (!first.startsWith('ep-'))
|
|
253
|
+
return null;
|
|
254
|
+
return first.endsWith('-pooler') ? first.slice(0, -'-pooler'.length) : first;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Whether `host` is on a list of hosts and endpoint ids: an entry matches the host itself, any subdomain of it, and,
|
|
258
|
+
* for a host whose first label is an endpoint id, that endpoint with or without `-pooler`, on any domain.
|
|
259
|
+
*/
|
|
260
|
+
export function isListedHost(host, entries) {
|
|
261
|
+
const h = host.toLowerCase();
|
|
262
|
+
const endpoint = endpointOf(h);
|
|
263
|
+
return entries.some((raw) => {
|
|
264
|
+
const entry = raw.trim().toLowerCase();
|
|
265
|
+
if (entry === '')
|
|
266
|
+
return false;
|
|
267
|
+
if (h === entry || h.endsWith(`.${entry}`))
|
|
268
|
+
return true;
|
|
269
|
+
return endpoint !== null && endpointOf(entry) === endpoint;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/** A host-shaped run of a text: labels of letters, digits and `-`, joined by dots (`ep-…-pooler.c-1.example`, `ep-…`). */
|
|
273
|
+
const HOST_TOKEN = /[a-z0-9-]+(?:\.[a-z0-9-]+)*/gi;
|
|
274
|
+
/**
|
|
275
|
+
* A run of a text between the ASCII characters that end a host in a URL, a keyword/value string or a shell line. ASCII
|
|
276
|
+
* only: what a character outside ASCII does inside a host is the URL parser's to say, never a guess made here (U+FEFF
|
|
277
|
+
* is white space to a regular expression and nothing at all to a host).
|
|
278
|
+
*/
|
|
279
|
+
// eslint-disable-next-line no-control-regex -- the range is the point: every ASCII control character and the space
|
|
280
|
+
const WORD = /[^\u0000-\u0020/?#@:=&,;'"`\\<>()[\]{}|^$!*+~]+/gu;
|
|
281
|
+
/** A word that a special-scheme host reading would change: it holds an escape, or a character outside ASCII. */
|
|
282
|
+
const MAPPABLE = /%|[^!-~]/u;
|
|
283
|
+
/**
|
|
284
|
+
* Every text a client might resolve a host from: the value as written and percent-decoded, each also without the tabs
|
|
285
|
+
* and line breaks a URL parser drops from inside a host, and each of its words as a special-scheme host reads one (the
|
|
286
|
+
* IDNA mapping).
|
|
287
|
+
*/
|
|
288
|
+
function resolvableTexts(value) {
|
|
289
|
+
const texts = new Set();
|
|
290
|
+
for (const form of decodedForms(value).flatMap((f) => [f, f.replace(/[\t\n\r]/g, '')])) {
|
|
291
|
+
texts.add(form);
|
|
292
|
+
for (const word of form.matchAll(WORD)) {
|
|
293
|
+
if (!MAPPABLE.test(word[0]))
|
|
294
|
+
continue;
|
|
295
|
+
const mapped = specialSchemeHostname(word[0]);
|
|
296
|
+
if (mapped)
|
|
297
|
+
texts.add(mapped);
|
|
298
|
+
// A word no host reading takes whole (white space outside ASCII between two hosts) is read piece by piece.
|
|
299
|
+
if (!mapped)
|
|
300
|
+
for (const piece of word[0].split(/\s+/u))
|
|
301
|
+
texts.add(specialSchemeHostname(piece) ?? piece);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return texts;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The entry of a list of hosts and endpoint ids that a value names, or null. One pass over every host-shaped token of
|
|
308
|
+
* every text a client might resolve from the value, so every connection-string shape counts: a URL (one host or
|
|
309
|
+
* several), a keyword/value string (`host=… dbname=…`), `PGHOST` (with a port), `PGOPTIONS endpoint=…`, a URL's
|
|
310
|
+
* `options=endpoint%3D…` or `?host=`, each as written, percent-encoded, or spelled with characters a URL host maps back
|
|
311
|
+
* to ASCII (fullwidth letters and hyphens, a soft hyphen, an ideographic full stop).
|
|
312
|
+
*/
|
|
313
|
+
export function listedEntryIn(value, entries) {
|
|
314
|
+
for (const text of resolvableTexts(value)) {
|
|
315
|
+
for (const token of text.matchAll(HOST_TOKEN)) {
|
|
316
|
+
const entry = entries.find((e) => isListedHost(token[0], [e]));
|
|
317
|
+
if (entry)
|
|
318
|
+
return entry.trim();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* An app's guard, from its own ids: `const db = createEndpointGuard({ production: ['ep-…'], allowed: ['ep-…'] })`,
|
|
325
|
+
* then `db.namesProduction(url)` wherever production must be refused and `db.isAllowed(url)` where only the allowed
|
|
326
|
+
* endpoints may be used. An id that is not a bare `ep-…` id throws here, at load: a mistyped list guards nothing.
|
|
327
|
+
*/
|
|
328
|
+
export function createEndpointGuard(config) {
|
|
329
|
+
const bare = (id, list) => {
|
|
330
|
+
const read = typeof id === 'string' ? endpointFromLabel(id) : null;
|
|
331
|
+
if (read === null || read !== id)
|
|
332
|
+
throw new Error(`createEndpointGuard: an entry of ${list} is not a bare ep-… endpoint id`);
|
|
333
|
+
return read;
|
|
334
|
+
};
|
|
335
|
+
const production = Object.freeze(config.production.map((id) => bare(id, 'production')));
|
|
336
|
+
const allowed = Object.freeze((config.allowed ?? []).map((id) => bare(id, 'allowed')));
|
|
337
|
+
if (production.length === 0)
|
|
338
|
+
throw new Error('createEndpointGuard: production names no endpoint id');
|
|
339
|
+
if (allowed.some((id) => production.includes(id)))
|
|
340
|
+
throw new Error('createEndpointGuard: an allowed endpoint id is also a production one');
|
|
341
|
+
const options = { anyDomain: config.anyDomain === true };
|
|
342
|
+
const productionIn = (value) => {
|
|
343
|
+
if (typeof value !== 'string')
|
|
344
|
+
return null;
|
|
345
|
+
return listedEntryIn(value, production);
|
|
346
|
+
};
|
|
347
|
+
const endpointId = (url) => neonEndpointId(url, options);
|
|
348
|
+
return Object.freeze({
|
|
349
|
+
production,
|
|
350
|
+
allowed,
|
|
351
|
+
endpointId,
|
|
352
|
+
productionIn,
|
|
353
|
+
namesProduction: (value) => productionIn(value) !== null,
|
|
354
|
+
isAllowed: (url) => {
|
|
355
|
+
const id = endpointId(url);
|
|
356
|
+
return id !== null && allowed.includes(id) && productionIn(url) === null;
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
}
|
package/dist/setup/README.md
CHANGED
|
@@ -29,7 +29,7 @@ A file that needs a DOM says so on its first line: `// @vitest-environment jsdom
|
|
|
29
29
|
| Defaults | `environment: 'node'`, 30 s test and hook timeouts, `retry: 0` (a retry is refused), `server-only` resolves to an empty module, `*.pw.ts` is excluded, the kit's setup file runs first. A repo's own `include` replaces the kit's catch-all glob rather than joining it; every other list is merged. The config helper loads from a CommonJS `vitest.config.ts` too (a package without `"type": "module"`). |
|
|
30
30
|
| Credentials | Every variable whose name holds `KEY`, `TOKEN`, `SECRET`, `DSN`, `PASSWORD`, `PASSWD`, `CREDENTIAL`, `PRIVATE`, `WEBHOOK`, `COOKIE`, `SESSION` or `AUTH`, and every variable whose value is a URL with a password, is deleted. `keepEnv` keeps names; `stripEnv` deletes more. Set the placeholders your code needs in your own setup file. |
|
|
31
31
|
| Network | `fetch` and every TCP socket (`http`, `https`, `tls`, undici, database drivers) are refused unless the host is loopback, on `allowHosts` (`*.example.test` matches subdomains), or the host of a database URL that passed the check below (`allowDatabaseHosts`, default true). Only a URL the credential strip kept counts, so name the test database in `keepEnv` or set it in your own setup file. Neon's serverless HTTP driver sends a query to the region's gateway (`api.<region>.neon.tech`, the database named in a header), not to the database's host: a repo that runs it against a Neon branch lists that gateway in `allowHosts` itself, knowing it serves every database in the region (the credential strip is what keeps another database's password out). A refused request fails the test even when the code swallowed the error. `allowNetworkHosts(host)` adds one for the rest of a file. |
|
|
32
|
-
| Production databases | A Postgres URL in the environment whose host is on your production list (a host, a subdomain of it, or a compute endpoint id `ep-…` with or without `-pooler
|
|
32
|
+
| Production databases | A Postgres URL in the environment whose host is on your production list (a host, a subdomain of it, or a compute endpoint id `ep-…` with or without `-pooler`; in any spelling a client still resolves to it, such as percent-encoded, fullwidth `ep-…` or with a soft hyphen inside) is refused before any test runs, and again before the tests (your own setup file may set the URL). So is a URL that names an entry anywhere else in it (`?host=`, `options=endpoint%3D…`), and any value that holds a Postgres URL or opens with a Postgres scheme (`postgres:user:pw@host/db`) and names one anywhere in it, so a URL the parser rejects or a password with a quote or a space hides nothing. A keyword/value string (`host=… dbname=…`) is not read here: `tk-dev-guard` reads those. The refusal names the list entry, never the URL. |
|
|
33
33
|
| `.env*` files | A Postgres URL in the environment that names the same database as any `.env*` file at the root is refused: those hold a developer's own connections. Every `postgres://` text counts, comments included, except lines keyed by `testDatabaseEnv`. |
|
|
34
34
|
| React warnings | A React or Radix warning (`console.error` / `console.warn`) fails the test, also when the test muted the console (`vi.spyOn(console, 'error').mockImplementation(() => {})`, `console.error = vi.fn()`, `console.error = () => {}`). |
|
|
35
35
|
| DOM files | Under jsdom: a `matchMedia` stub, `localStorage` / `sessionStorage` re-bound on Node 26, Testing Library's `cleanup` after each test (imported by the setup; with `domCleanup: 'deferred'`, after the first test instead, so a file can put React's devtools hook in place before react-dom loads), and jest-dom's matchers when installed. |
|
package/dist/setup/database.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A host as a client resolves it, in ASCII where it has an ASCII reading: without the tab and line breaks a URL
|
|
3
|
+
* parser drops (dropped first, as it does, so one inside a `%XX` escape does not break the escape), then {@link mapped}. A `postgres:` URL keeps its host as written, but the Neon driver re-reads the string as an
|
|
4
|
+
* `http:` URL and node-postgres percent-decodes it, so `ep-…`, `e%70-…` and `ep-pr<soft hyphen>od-…` all connect to
|
|
5
|
+
* `ep-prod-…`. It drops more than a URL parser does (a guard errs on refusing).
|
|
6
|
+
*/
|
|
7
|
+
export declare function canonicalHost(text: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Every reading of a text a host is looked for in: as written, mapped, and as {@link canonicalHost} reads it (a
|
|
10
|
+
* dropped character joins what stood on either side of it, which can hide a host as well as show one), and the last
|
|
11
|
+
* two without libpq's backslash escapes. Then the same of the text percent-decoded, up to three times: a route encoded
|
|
12
|
+
* twice (`options=endpoint%253D…`) is decoded by the client that reads the parameter.
|
|
13
|
+
*/
|
|
14
|
+
export declare function hostSpellings(text: string): string[];
|
|
1
15
|
/** The endpoint id of a host whose first label is one (`ep-quiet-sun-123-pooler.region.example` → `ep-quiet-sun-123`). */
|
|
2
16
|
export declare function endpointOf(host: string): string | null;
|
|
3
17
|
/**
|
|
@@ -14,15 +28,33 @@ export declare function envFilesAt(root: string): string[];
|
|
|
14
28
|
* `export KEY=…`, with or without spaces).
|
|
15
29
|
*/
|
|
16
30
|
export declare function protectedPlaces(root: string, testDatabaseEnv?: readonly string[]): string[];
|
|
17
|
-
/** Whether `host` is on the production list. */
|
|
31
|
+
/** Whether `host` is on the production list, in any spelling a client resolves to a listed host ({@link hostReadings}). */
|
|
18
32
|
export declare function isProductionHost(host: string, productionHosts: readonly string[]): boolean;
|
|
33
|
+
/** A character outside ASCII. */
|
|
34
|
+
export declare const NON_ASCII: RegExp;
|
|
35
|
+
/**
|
|
36
|
+
* The production list entry a value names anywhere in it, or null. One pass over every host-shaped token of the value,
|
|
37
|
+
* in each of its {@link hostSpellings} (the characters a URL parser drops are dropped before the tokens are cut), so
|
|
38
|
+
* every connection-string shape counts: a URL (one host or several), its `?host=` and `options=endpoint%3D…`, a
|
|
39
|
+
* keyword/value string (`host=… dbname=…`), `PGHOST` (with a port), `PGOPTIONS endpoint=…`. A token of numbers alone is
|
|
40
|
+
* read as an address only where a host stands ({@link inHostPosition}): elsewhere it is a version or a count
|
|
41
|
+
* (`npm/10.8.2` is not `10.8.0.2`, `web@10.0.5` is not `10.0.0.5`).
|
|
42
|
+
*/
|
|
43
|
+
export declare function productionEntryIn(value: string, productionHosts: readonly string[]): string | null;
|
|
19
44
|
export interface DatabaseRules {
|
|
20
45
|
productionHosts: readonly string[];
|
|
21
46
|
protectedPlaces: readonly string[];
|
|
22
47
|
}
|
|
23
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Throws when `url` names a production list entry (as its host, or anywhere else in it: `?host=`, `options=endpoint…`;
|
|
50
|
+
* also when it does not parse as a URL at all) or a database a `.env*` file names. The message carries the matched list entry, never the URL or any part of it.
|
|
51
|
+
*/
|
|
24
52
|
export declare function assertSafeDatabaseUrl(url: string, rules: DatabaseRules, label?: string): void;
|
|
25
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Runs {@link assertSafeDatabaseUrl} over every Postgres URL any variable of `env` holds: in the value as written, and
|
|
55
|
+
* without the characters a URL parser drops (a tab, a line break, a zero-width no-break space), which would otherwise
|
|
56
|
+
* end the URL where one stands.
|
|
57
|
+
*/
|
|
26
58
|
export declare function assertSafeDatabaseEnv(env: NodeJS.ProcessEnv, rules: DatabaseRules): void;
|
|
27
59
|
/** The hosts of every Postgres URL `env` holds: what the network guard lets through once the URLs passed the check. */
|
|
28
60
|
export declare function databaseHostsIn(env: NodeJS.ProcessEnv): string[];
|