@starci/hfs 4.7.10 → 4.7.12
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 +6 -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/engine/digest.mjs +16 -0
- 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 +5450 -3964
- package/runtime/packages/cli/src/validate-args.mjs +12 -1
- package/runtime/scripts/api/fs/is-link-like.mjs +1 -0
- package/runtime/scripts/api/sops/lib.mjs +5 -1
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +1 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +1 -2
- package/runtime/scripts/hfs/db-plan-verb.mjs +4 -5
- package/runtime/scripts/hfs/rule-catalog.mjs +3 -2
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
- package/runtime/scripts/hfs/rules/doc-fresh.mjs +1 -1
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +1 -1
- package/runtime/scripts/hfs/rules/release-provenance.mjs +1 -1
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks-release.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +1 -0
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +1 -1
- package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +1 -1
- package/runtime/scripts/lib/path-key.mjs +2 -0
- package/runtime/scripts/lib/secret-patterns.mjs +1 -1
- package/runtime/scripts/lib/ts-analysis.mjs +8 -4
- 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
|
}
|
|
@@ -15,6 +15,7 @@ import { realpathOr } from '../../lib/fs-kind.mjs';
|
|
|
15
15
|
export function isLinkLike(p, { parentReal = null, stat = null } = {}) {
|
|
16
16
|
let st = stat;
|
|
17
17
|
if (!st) { try { st = fs.lstatSync(p); } catch { return false; } }
|
|
18
|
+
if (!st) return false;
|
|
18
19
|
if (st.isSymbolicLink()) return true;
|
|
19
20
|
if (!st.isDirectory()) return false;
|
|
20
21
|
try { fs.readlinkSync(p); return true; } catch { /* not a link readlink can read */ }
|
|
@@ -104,7 +104,11 @@ export const SELECTED_AGE_VERSIONS = new Set(['1.2.1', '1.3.1']);
|
|
|
104
104
|
const IDENTITY_TIMEOUT_MS = 20_000;
|
|
105
105
|
const CONTROLLED_ENV = new Set(['SOPS_AGE_KEY', 'SOPS_AGE_KEY_FILE', 'SOPS_AGE_KEY_CMD', 'SOPS_AGE_SSH_PRIVATE_KEY_FILE', 'SOPS_AGE_SSH_PRIVATE_KEY_CMD', 'SOPS_AGE_RECIPIENT', 'SOPS_KEYSERVICE', 'SOPS_ENABLE_LOCAL_KEYSERVICE', 'SOPS_DECRYPTION_ORDER', 'SOPS_KMS_ARN', 'SOPS_GCP_KMS_IDS', 'SOPS_HUAWEICLOUD_KMS_IDS', 'SOPS_AZURE_KEYVAULT_URLS', 'SOPS_VAULT_URIS', 'SOPS_PGP_FP', 'SOPS_AGE_RECIPIENTS', 'HOME', 'USERPROFILE', 'APPDATA', 'XDG_CONFIG_HOME']);
|
|
106
106
|
|
|
107
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* Construct only the child map; empty desktop roots make Go return directory errors, not fallback paths.
|
|
109
|
+
* @param {object} env The admitted caller environment.
|
|
110
|
+
* @param {string|null} inlineName Selected inline identity name, or null to omit every identity.
|
|
111
|
+
*/
|
|
108
112
|
function isolatedSopsEnv(env, inlineName = 'SOPS_AGE_KEY') {
|
|
109
113
|
const out = Object.fromEntries(Object.entries(env).filter(([name]) => !CONTROLLED_ENV.has(name.toUpperCase())));
|
|
110
114
|
out.HOME = ''; out.USERPROFILE = ''; out.APPDATA = ''; out.XDG_CONFIG_HOME = '';
|
|
@@ -65,7 +65,7 @@ function templatePatternOf(node) {
|
|
|
65
65
|
return [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.').map(segment => {
|
|
66
66
|
if (!segment.includes('\u0000')) return segment;
|
|
67
67
|
const parts = segment.split('\u0000');
|
|
68
|
-
return parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map(escapeRegExp).join('.*')}$`, 'su');
|
|
68
|
+
return parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map((part) => escapeRegExp(part)).join('.*')}$`, 'su');
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -72,8 +72,7 @@ function stronglyConnected(adjacency) {
|
|
|
72
72
|
for (const start of adjacency.keys()) {
|
|
73
73
|
if (state.index.has(start)) continue;
|
|
74
74
|
startTraversal(state, adjacency, start);
|
|
75
|
-
|
|
76
|
-
const frame = state.work.at(-1);
|
|
75
|
+
for (let frame = state.work.at(-1); frame; frame = state.work.at(-1)) {
|
|
77
76
|
const next = frame.iterator.next();
|
|
78
77
|
if (next.done) {
|
|
79
78
|
finishFrame(state, frame);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// app db plan: compare migration-owned state without fetching commands or Vault values.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { sha256 } from '../../engine/digest.mjs';
|
|
5
5
|
import pg from 'libpg-query';
|
|
6
6
|
import { parse as parseToml } from 'smol-toml';
|
|
7
7
|
import { invocationDir } from '../lib/roots.mjs';
|
|
@@ -19,12 +19,11 @@ const PRESENCE_SQL = `select
|
|
|
19
19
|
pg_catalog.to_regclass('vault.secrets') is not null as vault;`;
|
|
20
20
|
const INVENTORIES = {
|
|
21
21
|
migrations: 'select version from supabase_migrations.schema_migrations order by version;',
|
|
22
|
-
cron: 'select jobname as name, schedule, pg_catalog.
|
|
22
|
+
cron: 'select jobname as name, schedule, pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(command, \'UTF8\')), \'hex\') as "commandHash", active from cron.job order by jobid;',
|
|
23
23
|
queues: 'select queue_name as name from pgmq.meta order by queue_name;',
|
|
24
24
|
vault: 'select name from vault.secrets order by name;',
|
|
25
25
|
};
|
|
26
26
|
const names = (nodes) => sqlStringParts(nodes).join('.');
|
|
27
|
-
const hash = (command) => createHash('md5').update(command).digest('hex');
|
|
28
27
|
const OWNERSHIP_CALLS = new Set(['pgmq.create', 'pgmq.drop_queue', 'cron.schedule', 'cron.unschedule', 'cron.schedule_in_database', 'cron.alter_job']);
|
|
29
28
|
|
|
30
29
|
const parseSql = sqlParser(pg, () => { throw new DbVerbError('migration SQL could not be inventoried safely'); });
|
|
@@ -45,7 +44,7 @@ function inventoryCall(state, call) {
|
|
|
45
44
|
const job = requiredLiteral(call, 0, ['job_name']);
|
|
46
45
|
const schedule = requiredLiteral(call, 1, ['schedule']);
|
|
47
46
|
const command = requiredLiteral(call, 2, ['command']);
|
|
48
|
-
state.cron.set(job, { schedule, commandHash:
|
|
47
|
+
state.cron.set(job, { schedule, commandHash: sha256(command), active: true });
|
|
49
48
|
} else if (name === 'cron.unschedule') {
|
|
50
49
|
state.cron.delete(requiredLiteral(call, 0, ['job_name']));
|
|
51
50
|
} else if (name === 'cron.schedule_in_database' || name === 'cron.alter_job') {
|
|
@@ -224,7 +223,7 @@ function compare(expected, actual, kind) {
|
|
|
224
223
|
for (const row of actual) {
|
|
225
224
|
const key = kind === 'migrations' ? row.version : row.name;
|
|
226
225
|
if (key !== null && (typeof key !== 'string' || key.length === 0)) throw new DbVerbError('Supabase returned an invalid inventory identifier');
|
|
227
|
-
if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{
|
|
226
|
+
if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{64}$/.test(row.commandHash ?? '') || typeof row.active !== 'boolean')) throw new DbVerbError('Supabase returned invalid cron metadata');
|
|
228
227
|
if (!unmatched.delete(key)) unexpected += 1;
|
|
229
228
|
else if (kind === 'cron') {
|
|
230
229
|
const desired = expected.get(key);
|
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
5
5
|
import { parseYamlCached } from '../lib/yaml-cached.mjs';
|
|
6
6
|
import { jsonClone } from '../lib/json-clone.mjs';
|
|
7
|
+
import { byCodeUnit } from '../lib/list.mjs';
|
|
7
8
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
8
9
|
import { SEMVER } from './manifest-shape.mjs';
|
|
9
10
|
import { enforcerJudgedInEdition, judgedInEdition, ruleEditionProblems } from './edition-slots.mjs';
|
|
@@ -39,7 +40,7 @@ function relativePathProblem(value, label, bad, anchor = false) {
|
|
|
39
40
|
const parts = typeof value === 'string' ? value.split('#') : [];
|
|
40
41
|
const file = parts[0];
|
|
41
42
|
const valid = typeof value === 'string' && value.trim() === value && value.length > 0
|
|
42
|
-
&& !/[\r\n
|
|
43
|
+
&& !value.includes('\0') && !/[\r\n\\]/.test(value) && !/^(?:\/|[a-z][a-z0-9+.-]*:)/i.test(value) && !file.includes(':')
|
|
43
44
|
&& !file.split('/').some((part) => ['', '.', '..'].includes(part))
|
|
44
45
|
&& (parts.length === 1 || (anchor && parts.length === 2 && /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(parts[1])));
|
|
45
46
|
if (!valid) bad.push(`${label} must be a repository-relative path${anchor ? ' with an optional #anchor' : ''}`);
|
|
@@ -51,7 +52,7 @@ function unknownKeys(item, allowed, label, bad) {
|
|
|
51
52
|
|
|
52
53
|
function nonEmptyList(value, label, bad) {
|
|
53
54
|
if (!Array.isArray(value) || !value.length) { bad.push(`${label} must be a non-empty list`); return false; }
|
|
54
|
-
const normalized = value.map((value) => JSON.stringify(value, (_key, item) => isPlainObject(item) ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item));
|
|
55
|
+
const normalized = value.map((value) => JSON.stringify(value, (_key, item) => isPlainObject(item) ? Object.fromEntries(Object.keys(item).sort(byCodeUnit).map((key) => [key, item[key]])) : item));
|
|
55
56
|
if (new Set(normalized).size !== value.length) bad.push(`${label} repeats a value`);
|
|
56
57
|
return true;
|
|
57
58
|
}
|
|
@@ -8,7 +8,7 @@ const DEPLOY = /(?:^|\/)deploy\//;
|
|
|
8
8
|
const SCRIPT = /(?:^|\/)scripts\//;
|
|
9
9
|
const COMPOSE = /(?:^|\/)(?:docker-)?compose(?:[.-][^/]*)?\.ya?ml$/;
|
|
10
10
|
const DOCKERFILE = /(?:^|\/)Dockerfile(?:\.[^/]*)?$/;
|
|
11
|
-
const CREDENTIAL_FILE = /(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|secrets\/)
|
|
11
|
+
const CREDENTIAL_FILE = /(?:(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|secrets\/))|(?:\.enc$)/i;
|
|
12
12
|
const HOST_CRON = /\b(?:crontab|anacron|crond|supercronic)\b|\/etc\/cron(?:\b|[./])|\bcron\.d\b|\/var\/spool\/cron\b|@(?:reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)\b|\b[\w.@-]+\.timer\b|\bOn(?:Calendar|BootSec|UnitActiveSec|ActiveSec)\s*=|(?:^|[\s;|&"'])at\s+(?:(?:-[a-z]+\s+)*)((?:-[ft]\b)|now\b|tomorrow\b|\d{1,2}:\d{2}\b|midnight\b|noon\b)/im;
|
|
13
13
|
const HOST_CRON_PATH = /(?:^|\/)(?:crontab(?:$|\/)|cron\.d(?:\/|$)|[^/]+\.(?:timer|crontab)$)/;
|
|
14
14
|
const CRON_NUMERIC_FIELD = /^[\d*,/-]+$/;
|
|
@@ -228,7 +228,7 @@ function injectionTokens(repoRoot, tracked) {
|
|
|
228
228
|
const ts = loadTypescript(repoRoot, path.dirname(fileURLToPath(import.meta.url)));
|
|
229
229
|
if (!ts) return tokens;
|
|
230
230
|
for (const file of tracked) {
|
|
231
|
-
if (!/\.[cm]?[jt]sx?$/.test(file) || /(?:^|\/)(?:tests?|__tests__)
|
|
231
|
+
if (!/\.[cm]?[jt]sx?$/.test(file) || /(?:(?:^|\/)(?:tests?|__tests__)\/)|(?:(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$)/.test(file)) continue;
|
|
232
232
|
const text = readText(repoRoot, file) ?? '';
|
|
233
233
|
declaredTokens(ts, ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true), tokens);
|
|
234
234
|
}
|
|
@@ -37,7 +37,7 @@ function wireSchemaDigest(repoRoot, snapshots) {
|
|
|
37
37
|
for (const snapshot of [...snapshots].sort(byCodeUnit)) {
|
|
38
38
|
let source;
|
|
39
39
|
try { source = fs.readFileSync(path.join(repoRoot, snapshot), 'utf8'); } catch { return null; }
|
|
40
|
-
hash.update(`${snapshot}\0${source.
|
|
40
|
+
hash.update(`${snapshot}\0${source.replaceAll('\r\n', '\n')}\0`);
|
|
41
41
|
}
|
|
42
42
|
return hash.digest('hex');
|
|
43
43
|
}
|
|
@@ -79,7 +79,7 @@ function refProof(value) {
|
|
|
79
79
|
if (typeof value !== 'string') return 0;
|
|
80
80
|
const expression = unwrap(workflowExpression(value));
|
|
81
81
|
const alternatives = splitBoolean(expression, '||');
|
|
82
|
-
if (alternatives.length > 1) return alternatives.map(refProof).reduce((a, b) => a & b);
|
|
82
|
+
if (alternatives.length > 1) return alternatives.map(refProof).reduce((a, b) => a & b, RELEASE_REF);
|
|
83
83
|
const conjuncts = splitBoolean(expression, '&&');
|
|
84
84
|
if (conjuncts.length > 1) return conjuncts.map(refProof).reduce((a, b) => a | b, 0);
|
|
85
85
|
if (/^startsWith\(\s*github\.ref\s*,\s*(['"])refs\/tags\/v\1\s*\)$/.test(expression)) return RELEASE_REF;
|
|
@@ -7,7 +7,7 @@ const PLAINTEXT_LOAD = 'HFS_SCRIPT_PLAINTEXT_LOAD';
|
|
|
7
7
|
const XTRACE = 'HFS_SCRIPT_XTRACE';
|
|
8
8
|
const SERVICE_ROLE = 'HFS_SERVICE_ROLE_SCOPE';
|
|
9
9
|
const SUPABASE_OWNER = 'be.integrations.supabase';
|
|
10
|
-
const SECRET_FILE = /(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|\.nivo-lite(?:\/|$))
|
|
10
|
+
const SECRET_FILE = /(?:(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|\.nivo-lite(?:\/|$)))|(?:\.enc$)/iu;
|
|
11
11
|
const EXCLUDED_TREE = /(?:^|\/)(?:node_modules|\.git|\.claude|\.secrets|secrets|tests?|__tests__|docs|coverage|dist|build)(?:\/|$)/u;
|
|
12
12
|
const TEST_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/u;
|
|
13
13
|
const SOURCE = /\.(?:[cm]?[jt]sx?|sh|bash|zsh|ksh|ps1|psm1|bat|cmd|py|rb|pl)$/iu;
|
|
@@ -13,7 +13,7 @@ const SCRIPTS_DIR = /(?:^|\/)scripts\//;
|
|
|
13
13
|
const PACKAGE_JSON = /(?:^|\/)package\.json$/;
|
|
14
14
|
const SOURCE_FILE = /(?:^|\/)[^/.]+$|\.(?:sh|bash|zsh|mjs|cjs|js|ts|mts|py|ya?ml)$/;
|
|
15
15
|
const TEST_FILE = /\.(?:spec|test)\.[cm]?[jt]s$/;
|
|
16
|
-
const CREDENTIAL_FILE = /(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|secrets\/)
|
|
16
|
+
const CREDENTIAL_FILE = /(?:(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|secrets\/))|(?:\.enc$)/i;
|
|
17
17
|
const VERB_FILE = /^(render|provision|converge|verify|prune|rollback|release|backup|prepare|doctor|jobs|public)\.(?:mjs|js|cjs|sh)$/;
|
|
18
18
|
const COMMENT_LINE = /^[ \t]*(?:#|\/\/).*$/gm;
|
|
19
19
|
const LINE_CONTINUATION = /\\\r?\n\s*/g;
|
|
@@ -26,7 +26,7 @@ const DECLARATION = '.starcistacks/application-stacks.yaml';
|
|
|
26
26
|
const MAX_BYTES = 1024 * 1024;
|
|
27
27
|
const SOURCE = /^(?:be\/)?(?:src|apps)\/.*\.[cm]?[jt]s$/;
|
|
28
28
|
const TEST = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]s$|\/(?:tests|__tests__)\//;
|
|
29
|
-
const SECRET = /(?:^|\/)(?:secrets|\.claude|\.aws|\.codex)(?:\/|$)|(?:^|\/)\.env(?:\.|$)
|
|
29
|
+
const SECRET = /(?:(?:^|\/)(?:secrets|\.claude|\.aws|\.codex)(?:\/|$))|(?:(?:^|\/)\.env(?:\.|$))|(?:\.(?:enc|pem|key|p12|pfx)$)/i;
|
|
30
30
|
const CLI_ENTRY = /^(?:be\/)?dist\/apps\/cli\/[A-Za-z0-9_./-]+\.(?:js|mjs|cjs)$/;
|
|
31
31
|
const mapping = value => plain(value) ? value : {};
|
|
32
32
|
const present = value => typeof value === 'string' && Boolean(value.trim());
|
|
@@ -16,7 +16,7 @@ const mapping = value => plain(value) ? value : {};
|
|
|
16
16
|
const present = value => typeof value === 'string' && Boolean(value.trim());
|
|
17
17
|
const VERSION = /^[1-9][0-9]*$/;
|
|
18
18
|
const VERSIONED_SECRET = /-v[1-9][0-9]*$/;
|
|
19
|
-
const SENSITIVE_KEY = /password|passwd|secret|token|credential|private[_-]?key|api[_-]?key|service[_-]?role|(?:^|_)key
|
|
19
|
+
const SENSITIVE_KEY = /password|passwd|secret|token|credential|private[_-]?key|api[_-]?key|service[_-]?role|(?:(?:^|_)key$)/i;
|
|
20
20
|
const SOURCE = /^be\/(?:apps|src)\/.*\.[cm]?[jt]sx?$/;
|
|
21
21
|
const TEST_SOURCE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$|\/tests\//;
|
|
22
22
|
const REALTIME_SOURCE = /^be\/src\/features\/realtime\/[^/]+\//;
|
|
@@ -32,6 +32,7 @@ function appendShellCommand(commands, state, next) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function shellLineCommands(line, commands) {
|
|
35
|
+
/** @type {{quote: string | null, command: string, separator: string | null}} */
|
|
35
36
|
const state = { quote: null, command: '', separator: null };
|
|
36
37
|
for (let i = 0; i < line.length; i++) {
|
|
37
38
|
const char = line[i];
|
|
@@ -76,7 +76,7 @@ function expressions(value) {
|
|
|
76
76
|
|
|
77
77
|
/** Source markers travel inside parsed scalar expressions, preserving flow maps, quoted keys and repeated values without matching comments. */
|
|
78
78
|
export function workflowContextFindings({ path: file, text }) {
|
|
79
|
-
const marked = text.
|
|
79
|
+
const marked = text.replaceAll('${{', (match, offset) => `${match} __hfs_context_line_${text.slice(0, offset).split('\n').length}__ `);
|
|
80
80
|
const findings = [];
|
|
81
81
|
function inspect(value, keys) {
|
|
82
82
|
const allowed = allowedContexts(keys);
|
|
@@ -22,7 +22,7 @@ function checksumStep(checksum, value) {
|
|
|
22
22
|
function checksumWords(prefix, words) {
|
|
23
23
|
const codes = [...prefix].map((character) => character.codePointAt(0));
|
|
24
24
|
const expanded = [...codes.map((code) => code >>> 5), 0, ...codes.map((code) => code & 31)];
|
|
25
|
-
return [...expanded, ...words].reduce(checksumStep, 1);
|
|
25
|
+
return [...expanded, ...words].reduce((checksum, value) => checksumStep(checksum, value), 1);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function convertBits(input, from, to, pad) {
|
|
@@ -40,6 +40,8 @@ export const pathsOverlap = (a, b) => sameOrUnder(a, b) || sameOrUnder(b, a);
|
|
|
40
40
|
* `trim` blanks at the ends, `collapse` folds '//' runs to '/', `dot` drops a leading './',
|
|
41
41
|
* `glob` strips a trailing glob ('star': '*'/'**' with their slash, 'double': a '/**' only),
|
|
42
42
|
* `fold` lowercases. A trailing '/' always goes. Null reads as ''.
|
|
43
|
+
* @param {unknown} p
|
|
44
|
+
* @param {{trim?: boolean, collapse?: boolean, dot?: boolean, glob?: string|null, fold?: boolean}} [options]
|
|
43
45
|
*/
|
|
44
46
|
export const normPath = (p, { trim = false, collapse = false, dot = false, glob = null, fold = false } = {}) => {
|
|
45
47
|
let s = String(p ?? '');
|
|
@@ -173,7 +173,7 @@ function readmeBadgeText(text, manifestText) {
|
|
|
173
173
|
|
|
174
174
|
/** Whole-file secret judgement exempts only declared public badge values and preserves original line numbers and every provider shape. */
|
|
175
175
|
export function secretHitsInFile(file, fullText, { manifestText } = {}) {
|
|
176
|
-
const normalized = file.
|
|
176
|
+
const normalized = file.replaceAll('\\', '/');
|
|
177
177
|
let text = fullText;
|
|
178
178
|
if (/(?:^|\/)\.starcistacks\/application-stacks\.yaml$/.test(normalized)) text = manifestBadgeText(text);
|
|
179
179
|
if (/(?:^|\/)README\.md$/.test(normalized)) text = readmeBadgeText(text, manifestText);
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/** The expression under the wrappers selected by an AST check's contract. */
|
|
2
2
|
export function unwrapExpression(ts, expression, { awaitExpressions = false, typeAssertions = true, nonNullExpressions = true, satisfiesExpressions = true } = {}) {
|
|
3
|
-
while (expression
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
while (expression) {
|
|
4
|
+
const wrapper = ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
|
|
5
|
+
|| (typeAssertions && ts.isTypeAssertionExpression(expression)) || (nonNullExpressions && ts.isNonNullExpression(expression))
|
|
6
|
+
|| (satisfiesExpressions && (ts.isSatisfiesExpression?.(expression) ?? false))
|
|
7
|
+
|| (awaitExpressions && ts.isAwaitExpression(expression));
|
|
8
|
+
if (!wrapper) break;
|
|
9
|
+
expression = expression.expression;
|
|
10
|
+
}
|
|
7
11
|
return expression;
|
|
8
12
|
}
|
|
9
13
|
|
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
|
+
}
|