@starci/hfs 4.7.9 → 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.
@@ -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
- return { ok: true, values, args: defaultArgs(locals, values), global, localArgs, positionals };
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 config = loadDeployConfig({ repoRoot: ctx.cwd, env: flags.env ?? 'production' });
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 result = DOCKER_ACTIONS.has(action) ? await withDockerTransport(ctx, config, { sshCmd }, call) : await call(ctx);
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
+ }