@starci/hfs 4.7.10 → 4.7.11
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/CHANGELOG.md +3 -0
- package/db/local-migrate.mjs +19 -6
- package/db/migrate.mjs +74 -17
- package/package.json +1 -1
- package/release/gate.mjs +15 -5
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +2 -0
- package/runtime/packages/cli/src/catalog.generated.mjs +4470 -2987
- package/runtime/packages/cli/src/validate-args.mjs +12 -1
- package/stack/cli.mjs +14 -3
- package/stack/docker-transport.mjs +3 -1
- package/stack/remote.mjs +8 -2
- package/stack/target-binding.mjs +125 -0
|
@@ -120,6 +120,16 @@ const defaultArgs = (locals, values) => {
|
|
|
120
120
|
return args;
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
/** Select the catalogued action policy; a missing policy refuses before runtime or provider dispatch. */
|
|
124
|
+
const operationPolicyOf = (verb, positionals) => {
|
|
125
|
+
if (verb.operationPolicy === undefined) return null;
|
|
126
|
+
const policy = verb.operationPolicy?.[positionals[0]];
|
|
127
|
+
if (!policy || typeof policy.action !== 'string' || !Array.isArray(policy.credentials)) {
|
|
128
|
+
throw new Error('the selected action has no executable operation policy');
|
|
129
|
+
}
|
|
130
|
+
return policy;
|
|
131
|
+
};
|
|
132
|
+
|
|
123
133
|
/**
|
|
124
134
|
* Validate the arguments after a catalog group and verb.
|
|
125
135
|
*
|
|
@@ -166,7 +176,8 @@ export function validateArgs(argv, verb, globalFlags = []) {
|
|
|
166
176
|
requiredCheck(locals, values);
|
|
167
177
|
checkPositionals(verb, positionals, passthrough);
|
|
168
178
|
checkGlobals(verb, global);
|
|
169
|
-
|
|
179
|
+
const operationPolicy = operationPolicyOf(verb, positionals);
|
|
180
|
+
return { ok: true, values, args: defaultArgs(locals, values), global, localArgs, positionals, operationPolicy };
|
|
170
181
|
} catch (error) {
|
|
171
182
|
return { ok: false, code: 2, error: String(error?.message ?? error) };
|
|
172
183
|
}
|
package/stack/cli.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { stackContext } from './context.mjs';
|
|
|
7
7
|
import { loadDeployConfig } from './config.mjs';
|
|
8
8
|
import { withDockerTransport } from './docker-transport.mjs';
|
|
9
9
|
import { StackError, EXIT, refusal } from './errors.mjs';
|
|
10
|
+
import { parseTargetBinding, bindStackTarget, nativeToolEnvironment } from './target-binding.mjs';
|
|
10
11
|
|
|
11
12
|
/** The actions that drive the docker CLI of the operator machine (pointed at the manager by docker-transport.mjs when the key is sealed). */
|
|
12
13
|
const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollback', 'prune']);
|
|
@@ -14,7 +15,7 @@ const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollb
|
|
|
14
15
|
export const ACTIONS = Object.freeze(['prepare', 'doctor', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept']);
|
|
15
16
|
|
|
16
17
|
const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push']);
|
|
17
|
-
const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project']);
|
|
18
|
+
const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding']);
|
|
18
19
|
const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
|
|
19
20
|
|
|
20
21
|
/** Splits argv into { flags, positional }; an unknown flag or a flag without its value is a refusal. */
|
|
@@ -107,6 +108,12 @@ function print(ctx, action, flags, result) {
|
|
|
107
108
|
else for (const line of result.lines ?? []) ctx.stdout(`${line}\n`);
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
function nativeToolContext(ctx, binding) {
|
|
112
|
+
if (!binding) return ctx;
|
|
113
|
+
return Object.create(ctx, { spawn: { value: (command, args, options = {}) => ctx.spawn(command, args,
|
|
114
|
+
{ ...options, env: nativeToolEnvironment(options.env ?? ctx.env) }) } });
|
|
115
|
+
}
|
|
116
|
+
|
|
110
117
|
/** Runs one action; returns the exit code. `io.importer` lets a spec load fake action modules. */
|
|
111
118
|
export async function stackMain(argv, io = {}) {
|
|
112
119
|
const ctx = stackContext(io);
|
|
@@ -114,12 +121,16 @@ export async function stackMain(argv, io = {}) {
|
|
|
114
121
|
const { flags, positional } = parseFlags(argv.slice(1));
|
|
115
122
|
const action = argv[0];
|
|
116
123
|
if (!ACTIONS.includes(action)) throw refusal('STACK_USAGE', `starci app stack takes ${[...ACTIONS, 'up', 'down', 'status'].join(', ')}`);
|
|
117
|
-
const
|
|
124
|
+
const binding = parseTargetBinding(flags['target-binding'], 'docker-swarm');
|
|
125
|
+
if (binding && (!['deploy', 'verify'].includes(action) || flags['ssh-cmd'] || flags['skip-public'] || flags['skip-internal']))
|
|
126
|
+
throw refusal('STACK_USAGE', 'the bound native consumer supports only complete deploy or verify');
|
|
127
|
+
const config = bindStackTarget(ctx, loadDeployConfig({ repoRoot: ctx.cwd, env: flags.env ?? 'production' }), binding);
|
|
118
128
|
const options = OPTIONS[action]({ flags, positional, cwd: ctx.cwd });
|
|
119
129
|
const handler = await handlerOf(action, io.importer ?? ((file) => import(file)));
|
|
120
130
|
const sshCmd = flags['ssh-cmd'] ?? null;
|
|
121
131
|
const call = (context) => handler(context, config, { ...options, sshCmd });
|
|
122
|
-
const
|
|
132
|
+
const toolContext = nativeToolContext(ctx, binding);
|
|
133
|
+
const result = DOCKER_ACTIONS.has(action) ? await withDockerTransport(toolContext, config, { sshCmd }, call) : await call(toolContext);
|
|
123
134
|
print(ctx, action, flags, result);
|
|
124
135
|
return result.ok ? EXIT.ok : EXIT.failed;
|
|
125
136
|
} catch (error) {
|
|
@@ -17,6 +17,7 @@ const controlLines = (controlPath) => controlSettings(configPath(controlPath)).m
|
|
|
17
17
|
|
|
18
18
|
/** True when the operator's docker CLI has to be pointed at the manager by this module. */
|
|
19
19
|
function needed(ctx, config, sshCmd) {
|
|
20
|
+
if (config.nativeTarget) return true;
|
|
20
21
|
if (sshCmd || ctx.env.DOCKER_HOST || ctx.env.GITHUB_ACTIONS === 'true' || ctx.env.STARCI_STACK_SEALED_SSH === '0') return false;
|
|
21
22
|
const { sealed } = config.remote;
|
|
22
23
|
if (!sealed) return false;
|
|
@@ -27,6 +28,7 @@ function sshConfig(session, controlPath, windows) {
|
|
|
27
28
|
const at = session.account.lastIndexOf('@');
|
|
28
29
|
return [
|
|
29
30
|
`Host ${ALIAS}`, ` HostName ${session.account.slice(at + 1)}`, ` User ${session.account.slice(0, at)}`,
|
|
31
|
+
...(session.port ? [` Port ${session.port}`] : []),
|
|
30
32
|
` IdentityFile ${configPath(session.keyFile)}`, ' IdentitiesOnly yes', ` UserKnownHostsFile ${configPath(session.knownFile)}`,
|
|
31
33
|
` StrictHostKeyChecking ${session.strict}`, ' BatchMode yes', ' ConnectTimeout 20',
|
|
32
34
|
...(windows ? [] : controlLines(controlPath)), '',
|
|
@@ -102,7 +104,7 @@ export async function withDockerTransport(ctx, config, { sshCmd = null } = {}, b
|
|
|
102
104
|
const baseEnv = session ? dockerEnvironment(ctx, session, controlPath) : ctx.env;
|
|
103
105
|
const { env, executable } = privateSshEnvironment(ctx, baseEnv, dir, controlPath);
|
|
104
106
|
const configFile = path.join(env.HOME ?? '', '.ssh', 'config');
|
|
105
|
-
const options = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20'];
|
|
107
|
+
const options = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', ...(session?.port ? ['-p', String(session.port)] : [])];
|
|
106
108
|
const argv = session || executable !== 'ssh' ? [executable, ...options, '-F', configFile, '--', alias] : ['ssh', ...options, '--', alias];
|
|
107
109
|
connection = createSshSession(ctx, { argv, env, controlPath, cleanup });
|
|
108
110
|
const spawn = dockerSpawn(ctx, connection, env, alias);
|
package/stack/remote.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { tool } from './context.mjs';
|
|
|
14
14
|
import { restrictToUser, writePrivate } from './private-file.mjs';
|
|
15
15
|
import { refusal } from './errors.mjs';
|
|
16
16
|
import { createSshSession, sessionDirectory } from './ssh-session.mjs';
|
|
17
|
+
import { assertStackAccount } from './target-binding.mjs';
|
|
17
18
|
|
|
18
19
|
const ACCOUNT = /^[A-Za-z0-9._@-]+$/u;
|
|
19
20
|
const CI_VARIABLES = ['VPS_SSH_KEY', 'VPS_KNOWN_HOSTS', 'VPS_HOST', 'VPS_USER'];
|
|
@@ -92,9 +93,12 @@ function writeWithMode(file, text, mode) {
|
|
|
92
93
|
export function openSealedSession(ctx, config) {
|
|
93
94
|
const { sealed } = config.remote;
|
|
94
95
|
const account = opsAccount(ctx, config);
|
|
96
|
+
if (config.nativeTarget && account !== `${config.nativeTarget.user}@${config.nativeTarget.host}`)
|
|
97
|
+
throw refusal('STACK_REMOTE_OPS', 'the sealed SSH account differs from the accepted native target');
|
|
95
98
|
const key = binaryMember(ctx, config, sealed.sshKey, ['privateKey', 'key']);
|
|
96
99
|
if (!key.includes('PRIVATE KEY')) throw refusal('STACK_REMOTE_KEY', `${sealed.sshKey} does not hold a PEM private key`);
|
|
97
100
|
const known = binaryMember(ctx, config, sealed.knownHosts, ['knownHosts', 'known_hosts']).trim();
|
|
101
|
+
assertStackAccount(config.nativeTarget, account, known);
|
|
98
102
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-ssh-'));
|
|
99
103
|
const keyFile = path.join(dir, 'id');
|
|
100
104
|
const knownFile = path.join(dir, 'known_hosts');
|
|
@@ -113,13 +117,14 @@ export function openSealedSession(ctx, config) {
|
|
|
113
117
|
throw error;
|
|
114
118
|
}
|
|
115
119
|
const notes = known ? [] : ['owner-machine session without known_hosts: the host key is accepted on first contact (accept-new); verify it, CI never does this'];
|
|
116
|
-
return { account, dir, keyFile, knownFile, strict: known ? 'yes' : 'accept-new', notes, cleanup
|
|
120
|
+
return { account, dir, keyFile, knownFile, strict: known ? 'yes' : 'accept-new', notes, cleanup,
|
|
121
|
+
...(config.nativeTarget ? { port: config.nativeTarget.port } : {}) };
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
/** The owner-machine runner over that session. */
|
|
120
125
|
function sealedRunner(ctx, config) {
|
|
121
126
|
const session = openSealedSession(ctx, config);
|
|
122
|
-
const argv = ['ssh', '-T', '-i', session.keyFile, '-o', 'IdentitiesOnly=yes', '-o', `UserKnownHostsFile=${session.knownFile}`, '-o', `StrictHostKeyChecking=${session.strict}`, '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', session.account];
|
|
127
|
+
const argv = ['ssh', '-T', ...(session.port ? ['-p', String(session.port)] : []), '-i', session.keyFile, '-o', 'IdentitiesOnly=yes', '-o', `UserKnownHostsFile=${session.knownFile}`, '-o', `StrictHostKeyChecking=${session.strict}`, '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', session.account];
|
|
123
128
|
return sessionRunner(ctx, argv, session.dir, session.cleanup, session.notes);
|
|
124
129
|
}
|
|
125
130
|
|
|
@@ -143,6 +148,7 @@ function sshRunner(ctx, config, { sshCmd = null } = {}) {
|
|
|
143
148
|
*/
|
|
144
149
|
function withOwnerTransport(ctx, config, { sshCmd = null } = {}) {
|
|
145
150
|
const { sealed } = config.remote;
|
|
151
|
+
if (config.nativeTarget) return Object.create(ctx, { env: { value: { ...ctx.env, STARCI_STACK_SEALED_SSH: '1' }, enumerable: true } });
|
|
146
152
|
if (sshCmd || !sealed || ctx.env.GITHUB_ACTIONS === 'true' || ctx.env.STARCI_STACK_SEALED_SSH !== undefined) return ctx;
|
|
147
153
|
const present = [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(path.join(config.secretsDir, `${slug}.enc`)));
|
|
148
154
|
if (!present) return ctx;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Public endpoint assertions for admitted native consumers; this module never opens custody.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { refusal } from './errors.mjs';
|
|
5
|
+
|
|
6
|
+
const slug = value => typeof value === 'string' && /^[a-z][a-z0-9-]*$/.test(value);
|
|
7
|
+
const label = value => typeof value === 'string' && /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(value);
|
|
8
|
+
const host = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(value);
|
|
9
|
+
const port = value => Number.isInteger(value) && value > 0 && value <= 65535;
|
|
10
|
+
const denied = () => { throw refusal('STACKS_DEPLOY_CONFIG_INVALID', 'the native endpoint binding is invalid or differs from the selected owner target'); };
|
|
11
|
+
const exact = (value, fields) => value && typeof value === 'object' && !Array.isArray(value)
|
|
12
|
+
&& Object.keys(value).length === fields.length && fields.every(key => Object.hasOwn(value, key));
|
|
13
|
+
|
|
14
|
+
function ordinaryInput(root, file) {
|
|
15
|
+
if (typeof root !== 'string' || !path.isAbsolute(root)) denied();
|
|
16
|
+
const relative = path.relative(root, file);
|
|
17
|
+
if (!relative || path.isAbsolute(relative) || relative.split(path.sep).includes('..')) denied();
|
|
18
|
+
let current = root;
|
|
19
|
+
const parts = relative.split(path.sep);
|
|
20
|
+
for (let index = 0; index <= parts.length; index += 1) {
|
|
21
|
+
let stat;
|
|
22
|
+
try { stat = fs.lstatSync(current); } catch { denied(); }
|
|
23
|
+
if (stat.isSymbolicLink() || (index === parts.length ? !stat.isFile() || stat.nlink !== 1 : !stat.isDirectory())) denied();
|
|
24
|
+
if (index < parts.length) current = path.join(current, parts[index]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A bound native owner opens only an ordinary envelope below its actual app, without ancestor links. */
|
|
29
|
+
export const assertBoundCustody = (root, file) => ordinaryInput(root, file);
|
|
30
|
+
|
|
31
|
+
/** Bound database commands require the app-local launcher; they cannot discover a global replacement. */
|
|
32
|
+
export function boundSupabaseLauncher(root) {
|
|
33
|
+
const launcher = path.join(root, 'node_modules', 'supabase', 'dist', 'supabase.js');
|
|
34
|
+
ordinaryInput(root, launcher);
|
|
35
|
+
return launcher;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Native custody can use AGE; tools that do not decrypt custody must not inherit that identity. */
|
|
39
|
+
export function nativeToolEnvironment(env) {
|
|
40
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => !/^SOPS_/i.test(key)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Bound database tools receive only their selected private password and native routing, without ambient libpq or provider credentials. */
|
|
44
|
+
export function nativeDatabaseEnvironment(env, binding, selected = {}, omit = []) {
|
|
45
|
+
const omitted = new Set(omit.filter(value => typeof value === 'string').map(value => value.toUpperCase()));
|
|
46
|
+
return { ...Object.fromEntries(Object.entries(nativeToolEnvironment(env)).filter(([key]) => !/^(?:PG[A-Z_]*|SUPABASE_)/i.test(key) && !omitted.has(key.toUpperCase()))),
|
|
47
|
+
'SUPABASE_ENV': binding.environment, ...selected };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Preserve a logical linked project while selecting a fixed native SQL endpoint without the CLI's linked Management finalizer. */
|
|
51
|
+
export function boundLinkedDatabaseUrl(binding, savedPooler = null) {
|
|
52
|
+
if (!binding || binding.mode !== 'linked' || !/^[a-z0-9]{20}$/.test(binding.projectRef)) denied();
|
|
53
|
+
if (savedPooler === null) return `postgresql://postgres@db.${binding.projectRef}.supabase.co:5432/postgres?sslmode=require`;
|
|
54
|
+
let url;
|
|
55
|
+
try {
|
|
56
|
+
if (typeof savedPooler !== 'string' || savedPooler.length > 8192) denied();
|
|
57
|
+
url = new URL(savedPooler.trim());
|
|
58
|
+
if (!['postgres:', 'postgresql:'].includes(url.protocol) || !/^[A-Za-z0-9.-]+\.pooler\.supabase\.com$/.test(url.hostname)
|
|
59
|
+
|| decodeURIComponent(url.username) !== `postgres.${binding.projectRef}` || url.pathname !== '/postgres'
|
|
60
|
+
|| url.port && !['5432', '6543'].includes(url.port) || url.hash
|
|
61
|
+
|| [...url.searchParams].some(([key, value]) => key !== 'sslmode' || !['require', 'verify-full'].includes(value))) denied();
|
|
62
|
+
} catch { denied(); }
|
|
63
|
+
url.password = '';
|
|
64
|
+
url.port = '5432';
|
|
65
|
+
if (!url.searchParams.has('sslmode')) url.searchParams.set('sslmode', 'require');
|
|
66
|
+
return url.toString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parse the internal public binding; ordinary commands without this flag retain their native behavior. */
|
|
70
|
+
export function parseTargetBinding(text, kind) {
|
|
71
|
+
if (text === undefined) return null;
|
|
72
|
+
let binding;
|
|
73
|
+
try { if (typeof text !== 'string' || text.length > 4096) denied(); binding = JSON.parse(text); } catch { denied(); }
|
|
74
|
+
if (binding?.kind !== kind || !slug(binding.environment)) denied();
|
|
75
|
+
if (kind === 'docker-swarm') {
|
|
76
|
+
if (!exact(binding, ['kind', 'environment', 'stack', 'host', 'user', 'port']) || !slug(binding.stack)
|
|
77
|
+
|| !host(binding.host) || !label(binding.user) || binding.port !== 22) denied();
|
|
78
|
+
} else if (kind === 'supabase-db') {
|
|
79
|
+
if (binding.mode === 'local') {
|
|
80
|
+
if (!exact(binding, ['kind', 'mode', 'environment', 'localProject', 'port']) || !label(binding.localProject) || !port(binding.port)) denied();
|
|
81
|
+
} else if (binding.mode === 'linked') {
|
|
82
|
+
if (!exact(binding, ['kind', 'mode', 'environment', 'projectRef']) || !/^[a-z0-9]{20}$/.test(binding.projectRef ?? '')) denied();
|
|
83
|
+
} else if (binding.mode === 'url-env') {
|
|
84
|
+
if (!exact(binding, ['kind', 'mode', 'environment', 'urlEnv', 'host', 'port', 'database', 'user'])
|
|
85
|
+
|| !/^[A-Z][A-Z0-9_]*$/.test(binding.urlEnv ?? '') || !host(binding.host) || !port(binding.port)
|
|
86
|
+
|| !label(binding.database) || !label(binding.user)) denied();
|
|
87
|
+
} else denied();
|
|
88
|
+
} else denied();
|
|
89
|
+
return Object.freeze(binding);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Require the selected sealed transport before any fallback can reach an operator's Docker or SSH context. */
|
|
93
|
+
export function bindStackTarget(ctx, config, binding) {
|
|
94
|
+
if (!binding) return config;
|
|
95
|
+
if (binding.kind !== 'docker-swarm' || binding.environment !== config.env || binding.stack !== config.stack
|
|
96
|
+
|| !config.remote.sealed || ctx.env.DOCKER_HOST || ctx.env.DOCKER_CONTEXT || ctx.env.GITHUB_ACTIONS
|
|
97
|
+
|| ctx.env.STARCI_STACK_SEALED_SSH !== undefined) denied();
|
|
98
|
+
for (const member of Object.values(config.remote.sealed)) {
|
|
99
|
+
const file = path.join(config.secretsDir, `${member}.enc`);
|
|
100
|
+
assertBoundCustody(config.repoRoot, file);
|
|
101
|
+
}
|
|
102
|
+
return { ...config, nativeTarget: binding };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Compare the effective private SSH account before key files, sessions or network effects exist. */
|
|
106
|
+
export function assertStackAccount(binding, account, knownHosts) {
|
|
107
|
+
if (!binding) return;
|
|
108
|
+
if (account !== `${binding.user}@${binding.host}` || binding.port !== 22 || !knownHosts.trim()) denied();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Compare the effective local Supabase identity after its existing config/environment resolver. */
|
|
112
|
+
export function assertLocalDatabase(binding, target) {
|
|
113
|
+
if (binding && (binding.mode !== 'local' || binding.localProject !== target.project || binding.port !== target.port)) denied();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Refuse a selected URL unless its non-secret endpoint exactly matches the accepted target. */
|
|
117
|
+
export function assertUrlDatabase(binding, value) {
|
|
118
|
+
if (!binding) return;
|
|
119
|
+
let url;
|
|
120
|
+
try { url = new URL(value); } catch { denied(); }
|
|
121
|
+
if (binding.mode !== 'url-env' || !['postgres:', 'postgresql:'].includes(url.protocol)
|
|
122
|
+
|| url.hostname !== binding.host || Number(url.port || '5432') !== binding.port
|
|
123
|
+
|| decodeURIComponent(url.username) !== binding.user || decodeURIComponent(url.pathname.slice(1)) !== binding.database
|
|
124
|
+
|| url.search || url.hash || !url.password) denied();
|
|
125
|
+
}
|