@starci/hfs 4.11.8 → 4.11.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +6 -3
  3. package/package.json +1 -1
  4. package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
  5. package/runtime/knowledge/hfs/rules.yaml +1 -1
  6. package/runtime/knowledge/hfs/slots.yaml +16 -1
  7. package/runtime/scripts/api/sops/lib.mjs +30 -0
  8. package/runtime/scripts/api/sops/update-keys.mjs +20 -0
  9. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
  10. package/runtime/scripts/hfs/secret-sops.mjs +15 -0
  11. package/runtime/scripts/hfs/secret-standard.mjs +54 -0
  12. package/runtime/scripts/hfs/secret.mjs +36 -6
  13. package/runtime/scripts/hfs/stack-deploy-config.mjs +11 -4
  14. package/src/main.mjs +1 -0
  15. package/stack/age-install.mjs +4 -3
  16. package/stack/context.mjs +32 -2
  17. package/stack/remote.mjs +4 -2
  18. package/stack/server-key.mjs +296 -0
  19. package/stack/ssh-session.mjs +7 -1
  20. package/sync/index.mjs +3 -0
  21. package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
  22. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
  23. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
  24. package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
  25. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
  26. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
  27. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +3 -3
  28. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +1 -2
  29. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  30. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  31. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
  32. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
package/stack/remote.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // remote.mjs - how the stack verbs reach the Swarm manager: the ssh runner, remote command quoting, the CI transport setup.
2
- // sshRunner(ctx, config, { sshCmd }) -> run(command, { input }) -> { status, stdout, stderr } (`.cleanup()`, `.notes`)
2
+ // sshRunner(ctx, config, { sshCmd }) -> run(command, { input }) -> { status, stdout, stderr } (`.cleanup()`, `.notes`, `.argv(command)`:
3
+ // the { command, args, env } a caller spawns itself to stream the remote stdout)
3
4
  // transports, in this order: --ssh-cmd "ssh vps" (no shell) -> the owner-machine session opened from the sealed members
4
5
  // (only when config.remote.sealed is declared and STARCI_STACK_SEALED_SSH=1) -> the ssh alias config.remote.host (CI after `connect`,
5
6
  // or the operator's own ssh config).
@@ -53,7 +54,8 @@ function makeRunner(ctx, argv, { cleanup = noop, notes = [], connection } = {})
53
54
  if (result.error) return { status: 127, stdout: '', stderr: `cannot run ${command}: ${result.error.message}` };
54
55
  return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
55
56
  };
56
- return Object.assign(run, { cleanup, notes });
57
+ const argvOf = (remote) => (connection ? connection.remoteArgv(lfOnly(remote)) : { command, args: [...head, lfOnly(remote)], env: ctx.env });
58
+ return Object.assign(run, { cleanup, notes, argv: argvOf });
57
59
  }
58
60
 
59
61
  function sshCommandArgv(sshCmd) {
@@ -0,0 +1,296 @@
1
+ // server-key.mjs - `starci app secret server-key <env> [--rotate | --backup github | --restore github] [--execute]`: the deploy
2
+ // server's own age identity, made ON the server and never leaving it, and the environment sealed to it (ruling
3
+ // app-secrets-one-standard, contexts/project/stacks.md PROJECT-STACKS-5).
4
+ // (none) the server keeps SERVER_AGE_IDENTITY when it exists, else age-keygen makes it there (umask 077, mode 600, owner
5
+ // root); only `age-keygen -y` (the public recipient) comes back. Locally the recipient joins the environment's rule
6
+ // beside the owner key, is recorded as deploy.remote.serverRecipient, and every member is wrapped again (sops
7
+ // updatekeys: the owner identity opens the data key only, no value is decrypted).
8
+ // --rotate a new identity is staged at <identity>.next, the members are wrapped to it, the server swaps it in keeping the old
9
+ // file as <identity>.retired-<UTC stamp> until the owner deletes it, then the old recipient leaves the rule.
10
+ // --backup github the identity streams from the server's ssh stdout into `gh secret set SOPS_AGE_KEY --env <env>` of the
11
+ // app's origin repository: the protected GitHub Environment <env> holds it as SOPS_AGE_KEY, the secret every CI job
12
+ // bound to <env> (environment: <env>) reads to open that environment's members; never printed, held or written locally.
13
+ // --restore github dispatches the managed .github/workflows/stack-key-restore.yml, which writes that secret back to the server
14
+ // only when its public key is the recorded deploy.remote.serverRecipient.
15
+ // Both GitHub actions warn when the repository itself holds a SOPS_AGE_KEY secret (names only): a job bound to no environment
16
+ // reads it, and the owner key never belongs in GitHub.
17
+ // The server is reached only through the deploy's own transport (remote.mjs withRunner): the same host, user and key source.
18
+ // Every effect is a dry run until --execute.
19
+ import path from 'node:path';
20
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
21
+ import { readFileSync } from 'node:fs';
22
+ import { configuredRecipients, environmentRecipients } from '../runtime/scripts/hfs/secret-init.mjs';
23
+ import { recordServerRecipient } from '../runtime/scripts/hfs/secret-standard.mjs';
24
+ import { sealedFiles } from '../runtime/scripts/hfs/secret-store.mjs';
25
+ import { github } from '../ci/github.mjs';
26
+ import { AGE_INSTALL, installAge } from './age-install.mjs';
27
+ import { STACKS_DIR, STACKS_FILE, loadDeployConfig } from './config.mjs';
28
+ import { firstLine } from './context.mjs';
29
+ import { failure, refusal } from './errors.mjs';
30
+ import { shellQuote, withRunner } from './remote.mjs';
31
+
32
+ /**
33
+ * The deploy server's age identity: sops' default key file of root, so a decrypt on the server reads it with nothing set, and
34
+ * SOPS_AGE_KEY_FILE names it where one is set. The one owner of this path; the restore workflow is rendered from it.
35
+ */
36
+ export const SERVER_AGE_IDENTITY = '/root/.config/sops/age/keys.txt';
37
+
38
+ /** The managed workflow `--restore github` dispatches (templates/app/ci-workflows*). */
39
+ export const RESTORE_WORKFLOW = 'stack-key-restore.yml';
40
+
41
+ const RECIPIENT = /^age1[a-z0-9]+$/u;
42
+ /** A command run as root: directly when the deploy user is root, else over passwordless sudo, the script read from stdin. */
43
+ const AS_ROOT = 'if [ "$(id -u)" -eq 0 ]; then sh -s; else sudo -n sh -s; fi';
44
+
45
+ /**
46
+ * The GitHub Environment secret that holds an environment's server identity. It carries the name the deploy workflows read
47
+ * (secrets.SOPS_AGE_KEY), scoped to the Environment <env>: for a job bound to <env> it overrides a repository secret of that name.
48
+ */
49
+ export const BACKUP_SECRET = 'SOPS_AGE_KEY';
50
+
51
+ /** The shell that keeps `file` when it is an ordinary file, else makes it with age-keygen (umask 077), then prints only its public key. */
52
+ export function ensureIdentityScript(file) {
53
+ const quoted = shellQuote(file);
54
+ return [
55
+ 'set -eu',
56
+ 'umask 077',
57
+ `f=${quoted}`,
58
+ 'if [ -e "$f" ] || [ -L "$f" ]; then',
59
+ ' if [ -L "$f" ] || [ ! -f "$f" ]; then echo "the identity path is not an ordinary file" >&2; exit 4; fi',
60
+ ' echo "identity kept" >&2',
61
+ 'else',
62
+ ' mkdir -p "$(dirname "$f")"',
63
+ ' age-keygen -o "$f" >/dev/null 2>&1',
64
+ ' echo "identity created" >&2',
65
+ 'fi',
66
+ 'chown root:root "$f"',
67
+ 'chmod 600 "$f"',
68
+ 'age-keygen -y "$f"',
69
+ ].join('\n');
70
+ }
71
+
72
+ /** The shell that prints only the public key of `file`, or exits 5 when there is none. */
73
+ export function publicKeyScript(file) {
74
+ return ['set -eu', `f=${shellQuote(file)}`, '[ -f "$f" ] || exit 5', 'age-keygen -y "$f"'].join('\n');
75
+ }
76
+
77
+ /** The shell that swaps the staged `<file>.next` in, keeping the old identity as `retired`, then prints only the new public key. */
78
+ export function swapIdentityScript(file, retired) {
79
+ return [
80
+ 'set -eu',
81
+ 'umask 077',
82
+ `f=${shellQuote(file)}`,
83
+ `r=${shellQuote(retired)}`,
84
+ 'n="$f.next"',
85
+ 'if [ ! -f "$n" ] || [ -L "$n" ]; then echo "no staged identity" >&2; exit 4; fi',
86
+ 'if [ -e "$r" ]; then echo "the retired name is taken" >&2; exit 4; fi',
87
+ 'mv "$f" "$r"',
88
+ 'mv "$n" "$f"',
89
+ 'chmod 600 "$f" "$r"',
90
+ 'age-keygen -y "$f"',
91
+ ].join('\n');
92
+ }
93
+
94
+ /** The one remote command that streams `file` to stdout as root; only --backup github runs it, into the stdin of gh. */
95
+ export const streamIdentityCommand = (file) =>
96
+ `if [ "$(id -u)" -eq 0 ]; then cat -- ${shellQuote(file)}; else sudo -n cat -- ${shellQuote(file)}; fi`;
97
+
98
+ /** The single public recipient a server script printed; anything else is refused without echoing a byte of it. */
99
+ function recipientOf(result, what) {
100
+ const text = String(result.stdout ?? '').trim();
101
+ if (result.status !== 0) throw failure('STACK_SERVER_KEY_REMOTE', `${what} failed on the server (exit ${result.status}): ${firstLine(result.stderr)}`);
102
+ if (!RECIPIENT.test(text)) throw failure('STACK_SERVER_KEY_OUTPUT', `${what} printed something other than one public age recipient; none of it is shown`);
103
+ return text;
104
+ }
105
+
106
+ /** The raw `environments.<env>.deploy.remote` block: the deploy's own transport, which this verb never invents. */
107
+ function declaredRemote(repoRoot, env) {
108
+ const declaration = parseYaml(readFileSync(path.join(repoRoot, STACKS_DIR, STACKS_FILE), 'utf8'));
109
+ const remote = declaration?.environments?.[env]?.deploy?.remote;
110
+ if (!remote || typeof remote !== 'object' || (remote.host === undefined && remote.sealed === undefined))
111
+ throw refusal('STACK_SERVER_KEY_NO_REMOTE', `environments.${env}.deploy.remote of ${STACKS_FILE} declares no transport: add host (the ssh alias the deploy uses) or sealed (ops, sshKey, knownHosts); server-key reaches the server only the way the deploy does`);
112
+ return remote;
113
+ }
114
+
115
+ /** Ensures age-keygen on the server: stack prepare's installer owns the install (with --execute); without it the exact commands. */
116
+ function ensureTool(run, execute, lines) {
117
+ if (run('command -v age-keygen').status === 0) return true;
118
+ const install = `install age on the server (one of: ${Object.values(AGE_INSTALL).join('; ')})`;
119
+ if (!execute) { lines.push(`dry run: age-keygen is missing on the server; --execute installs it with stack prepare's installer, or ${install}`); return false; }
120
+ const problems = [];
121
+ installAge(run, { note: (text) => lines.push(text), bad: (text) => problems.push(text) });
122
+ if (problems.length || run('command -v age-keygen').status !== 0)
123
+ throw refusal('STACK_SERVER_KEY_NO_AGE', `age-keygen is not on the server and could not be installed (${problems.join('; ') || 'still missing'}): ${install}`);
124
+ return true;
125
+ }
126
+
127
+ /** Wraps every member of the environment again to its rule (sops updatekeys); names only. */
128
+ function reseal(config, seam, lines) {
129
+ const members = sealedFiles(config.secretsDir);
130
+ for (const name of members) seam.updateKeys(path.join(config.secretsDir, name));
131
+ lines.push(members.length ? `wrapped ${members.length} member${members.length === 1 ? '' : 's'} of ${config.env} again: ${members.join(', ')}` : `${config.env} has no sealed member yet`);
132
+ }
133
+
134
+ /** Puts `add` on the environment's rule and takes `remove` off it, every other recipient (the owner key) kept, then reseals. */
135
+ function sealTo(ctx, config, { add = [], remove = [] }, lines) {
136
+ try {
137
+ environmentRecipients(config.repoRoot, config.env, { add, remove, owner: () => { throw new Error('owner is not named here'); }, stdout: (text) => lines.push(text.trimEnd()) });
138
+ } catch (error) {
139
+ throw refusal('STACK_SERVER_KEY_RULE', error.message);
140
+ }
141
+ reseal(config, ctx.sops, lines);
142
+ }
143
+
144
+ /** The environment's rule must already name a recipient other than the server's (the owner key), which every seal keeps. */
145
+ function requireOwnerRecipient(config, server = []) {
146
+ let rule = [];
147
+ try { rule = configuredRecipients(config.repoRoot, path.join(config.secretsDir, 'member.enc.yaml')); } catch { rule = []; }
148
+ if (!rule.some((recipient) => !server.includes(recipient)))
149
+ throw refusal('STACK_SERVER_KEY_NO_OWNER', `the ${config.env} rule of .sops.yaml names no owner key, so the server key would be its only recipient: starci app secret recipients ${config.env} --add owner`);
150
+ }
151
+
152
+ /** The members need the owner identity to be wrapped again: refused before the server is touched. */
153
+ function requireOwnerIdentity(ctx, config) {
154
+ const [first] = sealedFiles(config.secretsDir);
155
+ if (first) ctx.sops.requireIdentity?.(path.join(config.secretsDir, first));
156
+ }
157
+
158
+ const asRoot = (run, script) => run(AS_ROOT, { input: script });
159
+
160
+ const retiredName = (ctx) => `${SERVER_AGE_IDENTITY}.retired-${new Date(ctx.now()).toISOString().replaceAll('-', '').replaceAll(':', '').slice(0, 15)}Z`;
161
+
162
+ async function ensureKey(ctx, config, { execute }, deps) {
163
+ return withRunner(ctx, config, {}, deps, async (run, notes) => {
164
+ const lines = [...notes];
165
+ const tool = ensureTool(run, execute, lines);
166
+ if (!execute) {
167
+ const current = tool ? asRoot(run, publicKeyScript(SERVER_AGE_IDENTITY)) : { status: 5 };
168
+ if (current.status === 5) lines.push(`dry run: would create ${SERVER_AGE_IDENTITY} on the server (age-keygen, umask 077, mode 600, owner root) and read back only its public key`);
169
+ else lines.push(`dry run: the server holds ${SERVER_AGE_IDENTITY} (${recipientOf(current, 'age-keygen -y')}); it is kept`);
170
+ lines.push(`dry run: would add that recipient to the ${config.env} rule of .sops.yaml beside the owner key, record it as deploy.remote.serverRecipient and wrap every member of ${config.env} again (--execute)`);
171
+ return { ok: true, lines, data: { env: config.env, identity: SERVER_AGE_IDENTITY, execute } };
172
+ }
173
+ requireOwnerIdentity(ctx, config);
174
+ requireOwnerRecipient(config, [config.remote.serverRecipient].filter(Boolean));
175
+ const made = asRoot(run, ensureIdentityScript(SERVER_AGE_IDENTITY));
176
+ const recipient = recipientOf(made, 'age-keygen');
177
+ lines.push(`${String(made.stderr).includes('identity created') ? 'created' : 'kept'} ${SERVER_AGE_IDENTITY} on the server; public recipient ${recipient}`);
178
+ sealTo(ctx, config, { add: [recipient] }, lines);
179
+ recordServerRecipient(config.repoRoot, config.env, recipient);
180
+ lines.push(`recorded environments.${config.env}.deploy.remote.serverRecipient: ${recipient}`);
181
+ return { ok: true, lines, data: { env: config.env, identity: SERVER_AGE_IDENTITY, recipient, execute } };
182
+ });
183
+ }
184
+
185
+ async function rotateKey(ctx, config, { execute }, deps) {
186
+ return withRunner(ctx, config, {}, deps, async (run, notes) => {
187
+ const lines = [...notes];
188
+ if (!ensureTool(run, execute, lines)) return { ok: true, lines, data: { env: config.env, identity: SERVER_AGE_IDENTITY, execute } };
189
+ const current = asRoot(run, publicKeyScript(SERVER_AGE_IDENTITY));
190
+ if (current.status === 5) throw refusal('STACK_SERVER_KEY_NONE', `the server holds no ${SERVER_AGE_IDENTITY} to rotate: starci app secret server-key ${config.env} --execute`);
191
+ const old = recipientOf(current, 'age-keygen -y');
192
+ const retired = retiredName(ctx);
193
+ if (!execute) {
194
+ lines.push(`dry run: would stage a new identity at ${SERVER_AGE_IDENTITY}.next, wrap every member of ${config.env} to it beside ${old} and the owner key, swap it in keeping the old file as ${retired}, then remove ${old} from the rule (--execute)`);
195
+ return { ok: true, lines, data: { env: config.env, identity: SERVER_AGE_IDENTITY, previous: old, execute } };
196
+ }
197
+ requireOwnerIdentity(ctx, config);
198
+ requireOwnerRecipient(config, [old]);
199
+ const next = recipientOf(asRoot(run, ensureIdentityScript(`${SERVER_AGE_IDENTITY}.next`)), 'age-keygen');
200
+ lines.push(`staged ${SERVER_AGE_IDENTITY}.next on the server; public recipient ${next}`);
201
+ sealTo(ctx, config, { add: [next] }, lines);
202
+ recordServerRecipient(config.repoRoot, config.env, next);
203
+ const swapped = recipientOf(asRoot(run, swapIdentityScript(SERVER_AGE_IDENTITY, retired)), 'the identity swap');
204
+ if (swapped !== next) throw failure('STACK_SERVER_KEY_SWAP', `the server's identity reads ${swapped} after the swap, not the staged ${next}`);
205
+ lines.push(`swapped the new identity in; the old one is kept on the server as ${retired} until the owner deletes it`);
206
+ sealTo(ctx, config, { remove: old === next ? [] : [old] }, lines);
207
+ lines.push(`recorded environments.${config.env}.deploy.remote.serverRecipient: ${next}; ${old} left the ${config.env} rule`);
208
+ return { ok: true, lines, data: { env: config.env, identity: SERVER_AGE_IDENTITY, recipient: next, previous: old, retired, execute } };
209
+ });
210
+ }
211
+
212
+ /**
213
+ * The GitHub Environment that holds the backup, refused unless it exists in the app's origin repository and is protected: required
214
+ * reviewers, or deployment branches limited to main. Names what is missing.
215
+ */
216
+ export function protectedEnvironment(gh, env) {
217
+ let repo;
218
+ try { repo = gh.repository(); } catch { throw refusal('STACK_SERVER_KEY_GITHUB', 'the app\'s origin repository cannot be read with gh (gh repo view); authenticate gh in the app checkout'); }
219
+ let environment;
220
+ try { environment = gh.json(['api', `repos/${repo}/environments/${env}`]); } catch {
221
+ throw refusal('STACK_SERVER_KEY_GITHUB_ENVIRONMENT', `the GitHub Environment ${env} of ${repo} does not exist or cannot be read; create it with required reviewers or deployment branches limited to main`);
222
+ }
223
+ const reviewers = (environment?.protection_rules ?? []).some((rule) => rule?.type === 'required_reviewers' && (rule.reviewers ?? []).length > 0);
224
+ let mainOnly = false;
225
+ if (environment?.deployment_branch_policy?.custom_branch_policies === true) {
226
+ const policies = gh.json(['api', `repos/${repo}/environments/${env}/deployment-branch-policies`])?.branch_policies ?? [];
227
+ mainOnly = policies.length > 0 && policies.every((policy) => policy?.name === 'main' && (policy.type ?? 'branch') === 'branch');
228
+ }
229
+ if (!reviewers && !mainOnly)
230
+ throw refusal('STACK_SERVER_KEY_UNPROTECTED', `the GitHub Environment ${env} of ${repo} has no protection rule: it has no required reviewers and its deployment branches are not limited to main; add either before it holds the server identity`);
231
+ return repo;
232
+ }
233
+
234
+ /**
235
+ * The warning lines about a repository-level SOPS_AGE_KEY of `repo`, read by name only (gh api repos/<repo>/actions/secrets): a job
236
+ * bound to no Environment, or to one without its own SOPS_AGE_KEY, reads that one. Empty when the repository holds none.
237
+ */
238
+ export function repositoryKeyWarnings(gh, repo) {
239
+ let names;
240
+ try {
241
+ names = (gh.json(['api', `repos/${repo}/actions/secrets?per_page=100`])?.secrets ?? []).map((secret) => secret?.name);
242
+ } catch {
243
+ return [`warning: the repository secret names of ${repo} cannot be read (gh api repos/${repo}/actions/secrets needs admin access); make sure the repository holds no ${BACKUP_SECRET} of its own`];
244
+ }
245
+ if (!names.includes(BACKUP_SECRET)) return [];
246
+ return [`warning: ${repo} holds a repository secret ${BACKUP_SECRET}; a job bound to no Environment, or to one without its own ${BACKUP_SECRET}, reads it. Once every environment holds its server key (server-key <env> --backup github --execute), delete it: gh secret delete ${BACKUP_SECRET} --repo ${repo}. The owner key never belongs in GitHub.`];
247
+ }
248
+
249
+ async function backupKey(ctx, config, { execute }, deps) {
250
+ const gh = deps.github ?? github({ cwd: config.repoRoot, env: ctx.env });
251
+ const repo = protectedEnvironment(gh, config.env);
252
+ const warnings = repositoryKeyWarnings(gh, repo);
253
+ const sinkArgs = ['secret', 'set', BACKUP_SECRET, '--env', config.env, '--repo', repo];
254
+ const plan = `stream ${SERVER_AGE_IDENTITY} from the server's ssh stdout into gh ${sinkArgs.join(' ')} (the ${config.env} Environment's ${BACKUP_SECRET}, which every job bound to ${config.env} reads; never printed, held or written locally)`;
255
+ const data = { env: config.env, repo, secret: BACKUP_SECRET, repositorySecret: warnings.length > 0, execute };
256
+ if (!execute) return { ok: true, lines: [`dry run: would ${plan} (--execute)`, ...warnings], data };
257
+ return withRunner(ctx, config, {}, deps, async (run, notes) => {
258
+ const source = run.argv(streamIdentityCommand(SERVER_AGE_IDENTITY));
259
+ const sink = deps.ghCommand ?? { command: 'gh', args: [] };
260
+ const piped = await ctx.pipe(source, { command: sink.command, args: [...sink.args, ...sinkArgs], env: ctx.env, cwd: config.repoRoot });
261
+ if (piped.source !== 0) throw failure('STACK_SERVER_KEY_BACKUP', `the server could not stream ${SERVER_AGE_IDENTITY} (exit ${piped.source}); nothing was stored`);
262
+ if (piped.sink !== 0) throw failure('STACK_SERVER_KEY_BACKUP', `gh secret set ${BACKUP_SECRET} failed (exit ${piped.sink}): ${piped.stderr}`);
263
+ return { ok: true, lines: [...notes, `stored the server identity of ${config.env} as the secret ${BACKUP_SECRET} of the GitHub Environment ${config.env} of ${repo}`, ...warnings], data };
264
+ });
265
+ }
266
+
267
+ async function restoreKey(ctx, config, { execute }, deps) {
268
+ const recipient = config.remote.serverRecipient;
269
+ if (!recipient)
270
+ throw refusal('STACK_SERVER_KEY_NO_RECIPIENT', `environments.${config.env}.deploy.remote records no serverRecipient, so the restore cannot tell the server key from another identity: starci app secret server-key ${config.env} --execute records it`);
271
+ const gh = deps.github ?? github({ cwd: config.repoRoot, env: ctx.env });
272
+ const repo = protectedEnvironment(gh, config.env);
273
+ const warnings = repositoryKeyWarnings(gh, repo);
274
+ const args = ['workflow', 'run', RESTORE_WORKFLOW, '--repo', repo, '-f', `environment=${config.env}`, '-f', `recipient=${recipient}`];
275
+ const data = { env: config.env, repo, recipient, repositorySecret: warnings.length > 0, execute };
276
+ if (!execute) return { ok: true, lines: [`dry run: would run gh ${args.join(' ')}; the workflow writes the ${config.env} Environment's ${BACKUP_SECRET} to ${SERVER_AGE_IDENTITY} on the server when none is there and its public key is ${recipient} (--execute)`, ...warnings], data };
277
+ try { gh.run(args); } catch { throw failure('STACK_SERVER_KEY_RESTORE', `gh could not dispatch ${RESTORE_WORKFLOW} in ${repo}; is the managed workflow rendered (starci app sync --write) and pushed?`); }
278
+ return { ok: true, lines: [`dispatched ${RESTORE_WORKFLOW} in ${repo} for ${config.env}; follow it with gh run list --workflow ${RESTORE_WORKFLOW} --repo ${repo}`, ...warnings], data };
279
+ }
280
+
281
+ /**
282
+ * Runs one server-key action. `options`: { execute, rotate, backup, restore };
283
+ * `deps` (specs): { ssh (an injected runner), github (the gh port of ci/github.mjs), ghCommand ({ command, args } the pipe's sink) }.
284
+ */
285
+ export async function serverKey(ctx, env, options, deps = {}) {
286
+ const config = loadDeployConfig({ repoRoot: ctx.cwd, env });
287
+ declaredRemote(ctx.cwd, env);
288
+ for (const [flag, value] of [['backup', options.backup], ['restore', options.restore]])
289
+ if (value !== undefined && value !== 'github') throw refusal('STACK_USAGE', `--${flag} takes github`);
290
+ if ([options.rotate === true, options.backup !== undefined, options.restore !== undefined].filter(Boolean).length > 1)
291
+ throw refusal('STACK_USAGE', '--rotate, --backup and --restore are separate actions; pass one');
292
+ if (options.backup) return backupKey(ctx, config, options, deps);
293
+ if (options.restore) return restoreKey(ctx, config, options, deps);
294
+ if (options.rotate) return rotateKey(ctx, config, options, deps);
295
+ return ensureKey(ctx, config, options, deps);
296
+ }
@@ -136,6 +136,12 @@ export function createSshSession(ctx, { argv, env = ctx.env, controlPath, socket
136
136
  return ctx.spawn(command, [...controls, ...head, remoteCommand], { ...options, env });
137
137
  }
138
138
 
139
+ /** The argv `remote` would spawn, for a caller that streams the command's stdout itself ({ command, args, env }). */
140
+ function remoteArgv(remoteCommand) {
141
+ if (!windows) start();
142
+ return { command, args: [...controls, ...head, remoteCommand], env };
143
+ }
144
+
139
145
  function dockerHost(alias) {
140
146
  start();
141
147
  return windows ? host : `ssh://${alias}`;
@@ -169,7 +175,7 @@ export function createSshSession(ctx, { argv, env = ctx.env, controlPath, socket
169
175
  }
170
176
  }
171
177
 
172
- return { remote, dockerHost, close };
178
+ return { remote, remoteArgv, dockerHost, close };
173
179
  }
174
180
 
175
181
  /** A short private directory for an invocation whose SSH credentials already live in the operator or CI HOME. */
package/sync/index.mjs CHANGED
@@ -41,6 +41,7 @@ import { LIST } from '../runtime/scripts/lib/loosening-config.mjs';
41
41
  import { readTruths } from '../runtime/scripts/lib/loosening-state.mjs';
42
42
  import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
43
43
  import { bootstrapSection } from '../runtime/scripts/hfs/rules/database-constants.mjs';
44
+ import { SERVER_AGE_IDENTITY } from '../stack/server-key.mjs';
44
45
 
45
46
  export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
46
47
  const NODE_MAJOR = 22;
@@ -412,6 +413,8 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
412
413
  // The typed lint reads the types the fe packages declare (their dist, turbo.json: lint dependsOn ^build), so the packages are built first.
413
414
  lintPrefix: packages ? 'npm run codegen --silent && turbo run build --filter=./fe/packages/* && ' : 'npm run codegen --silent && ',
414
415
  nodeMajor: String(NODE_MAJOR),
416
+ // The deploy server's age identity the restore workflow writes (stack/server-key.mjs owns the path).
417
+ serverAgeIdentity: SERVER_AGE_IDENTITY,
415
418
  sonarKey: sonarKey ?? app.project,
416
419
  sonarHostLine: sonarHostLine(quality.sonar),
417
420
  sonarExclusions: sonarScope(presets).exclusions.join(','),
@@ -1,6 +1,8 @@
1
1
  {{header}}
2
2
  # Builds the image of every app on its release tag, so a Dockerfile that no longer builds fails that release.
3
3
  # It never pushes: publishing an image is the product's decision, not this file's.
4
+ # When an environment declares images, the job is bound to that protected GitHub environment and opens its sealed build
5
+ # arguments with that environment's SOPS_AGE_KEY (its server identity, starci app secret server-key <env> --backup github).
4
6
  name: images
5
7
 
6
8
  on:
@@ -7,7 +7,9 @@
7
7
  # operation rollback redeploys a stored release; "release" is 16 lowercase hex characters, empty means the previous accepted release
8
8
  # The environment input names a protected GitHub environment (required reviewers) that holds:
9
9
  # vars VPS_HOST, VPS_USER (the swarm manager); a variable named in deploy.passEnv, when the stack interpolates one
10
- # secrets SOPS_AGE_KEY (the age identity of .sops.yaml), VPS_SSH_KEY (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
10
+ # secrets SOPS_AGE_KEY (that environment's server identity, stored there by starci app secret server-key <env> --backup github;
11
+ # both jobs are bound to the environment, so they read it and never a repository secret of that name), VPS_SSH_KEY
12
+ # (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
11
13
  # Nothing else is a secret: images are pushed with the workflow token, which also reads the CI verdict of the tag's commit a production deploy
12
14
  # waits for (requireCi deploy.prod). Dispatch inputs reach scripts only through env.
13
15
  name: deploy stack
@@ -0,0 +1,58 @@
1
+ {{header}}
2
+ # Restores the deploy server's age identity from its GitHub Environment backup (starci app secret server-key <env> --backup github
3
+ # stored it; --restore github dispatches this file). It runs by hand only and writes {{serverAgeIdentity}} on the server when no
4
+ # identity is there and the secret's public key is the recipient input (deploy.remote.serverRecipient), with umask 077 and mode 600,
5
+ # owned by root; it never echoes the identity and never overwrites one.
6
+ # The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds:
7
+ # vars VPS_HOST, VPS_USER (the swarm manager)
8
+ # secrets SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read), VPS_SSH_KEY (CI-only deploy key),
9
+ # VPS_KNOWN_HOSTS (pinned host keys)
10
+ name: restore stack key
11
+
12
+ on:
13
+ workflow_dispatch:
14
+ inputs:
15
+ environment:
16
+ description: Protected environment holding the deploy transport and the server identity backup
17
+ required: true
18
+ type: environment
19
+ recipient:
20
+ description: The public age recipient of the server identity, deploy.remote.serverRecipient of that environment
21
+ required: true
22
+ type: string
23
+
24
+ permissions:
25
+ contents: read
26
+
27
+ concurrency:
28
+ group: stack-${{ inputs.environment }}
29
+ cancel-in-progress: false
30
+
31
+ jobs:
32
+ restore:
33
+ runs-on: ubuntu-latest
34
+ environment: ${{ inputs.environment }}
35
+ timeout-minutes: 10
36
+ steps:
37
+ - name: write the identity on the server
38
+ env:
39
+ RECIPIENT: ${{ inputs.recipient }}
40
+ AGE_IDENTITY: ${{ secrets.SOPS_AGE_KEY }}
41
+ VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
42
+ VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
43
+ VPS_HOST: ${{ vars.VPS_HOST }}
44
+ VPS_USER: ${{ vars.VPS_USER }}
45
+ shell: bash
46
+ run: |
47
+ set -euo pipefail
48
+ [[ "$RECIPIENT" =~ ^age1[a-z0-9]+$ ]] || { echo "recipient must be one public age recipient (age1...)" >&2; exit 1; }
49
+ [ -n "$AGE_IDENTITY" ] || { echo "SOPS_AGE_KEY is not set in this environment" >&2; exit 1; }
50
+ umask 077
51
+ dir="$(mktemp -d)"
52
+ trap 'rm -rf "$dir"' EXIT
53
+ printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
54
+ printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
55
+ # The server writes the identity beside its target, keeps it only when its public key is the recipient, then moves it in.
56
+ remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
57
+ printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
58
+ -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
@@ -1,6 +1,8 @@
1
1
  {{header}}
2
2
  # Builds each image declared by the lite app. The API image is required; a front-end image is emitted only when declared.
3
3
  # It runs on the release tag, so every image is built there, and never pushes.
4
+ # When an environment declares images, the job is bound to that protected GitHub environment and opens its sealed build
5
+ # arguments with that environment's SOPS_AGE_KEY (its server identity, starci app secret server-key <env> --backup github).
4
6
  name: images
5
7
 
6
8
  on:
@@ -7,7 +7,9 @@
7
7
  # operation rollback redeploys a stored release; "release" is 16 lowercase hex characters, empty means the previous accepted release
8
8
  # The environment input names a protected GitHub environment (required reviewers) that holds:
9
9
  # vars VPS_HOST, VPS_USER (the swarm manager); a variable named in deploy.passEnv, when the stack interpolates one
10
- # secrets SOPS_AGE_KEY (the age identity of .sops.yaml), VPS_SSH_KEY (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
10
+ # secrets SOPS_AGE_KEY (that environment's server identity, stored there by starci app secret server-key <env> --backup github;
11
+ # both jobs are bound to the environment, so they read it and never a repository secret of that name), VPS_SSH_KEY
12
+ # (CI-only deploy key), VPS_KNOWN_HOSTS (pinned host keys)
11
13
  # Nothing else is a secret: images are pushed with the workflow token, which also reads the CI verdict of the tag's commit a production deploy
12
14
  # waits for (requireCi deploy.prod). Dispatch inputs reach scripts only through env.
13
15
  name: deploy stack
@@ -0,0 +1,58 @@
1
+ {{header}}
2
+ # Restores the deploy server's age identity from its GitHub Environment backup (starci app secret server-key <env> --backup github
3
+ # stored it; --restore github dispatches this file). It runs by hand only and writes {{serverAgeIdentity}} on the server when no
4
+ # identity is there and the secret's public key is the recipient input (deploy.remote.serverRecipient), with umask 077 and mode 600,
5
+ # owned by root; it never echoes the identity and never overwrites one.
6
+ # The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds:
7
+ # vars VPS_HOST, VPS_USER (the swarm manager)
8
+ # secrets SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read), VPS_SSH_KEY (CI-only deploy key),
9
+ # VPS_KNOWN_HOSTS (pinned host keys)
10
+ name: restore stack key
11
+
12
+ on:
13
+ workflow_dispatch:
14
+ inputs:
15
+ environment:
16
+ description: Protected environment holding the deploy transport and the server identity backup
17
+ required: true
18
+ type: environment
19
+ recipient:
20
+ description: The public age recipient of the server identity, deploy.remote.serverRecipient of that environment
21
+ required: true
22
+ type: string
23
+
24
+ permissions:
25
+ contents: read
26
+
27
+ concurrency:
28
+ group: stack-${{ inputs.environment }}
29
+ cancel-in-progress: false
30
+
31
+ jobs:
32
+ restore:
33
+ runs-on: ubuntu-latest
34
+ environment: ${{ inputs.environment }}
35
+ timeout-minutes: 10
36
+ steps:
37
+ - name: write the identity on the server
38
+ env:
39
+ RECIPIENT: ${{ inputs.recipient }}
40
+ AGE_IDENTITY: ${{ secrets.SOPS_AGE_KEY }}
41
+ VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
42
+ VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
43
+ VPS_HOST: ${{ vars.VPS_HOST }}
44
+ VPS_USER: ${{ vars.VPS_USER }}
45
+ shell: bash
46
+ run: |
47
+ set -euo pipefail
48
+ [[ "$RECIPIENT" =~ ^age1[a-z0-9]+$ ]] || { echo "recipient must be one public age recipient (age1...)" >&2; exit 1; }
49
+ [ -n "$AGE_IDENTITY" ] || { echo "SOPS_AGE_KEY is not set in this environment" >&2; exit 1; }
50
+ umask 077
51
+ dir="$(mktemp -d)"
52
+ trap 'rm -rf "$dir"' EXIT
53
+ printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
54
+ printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
55
+ # The server writes the identity beside its target, keeps it only when its public key is the recipient, then moves it in.
56
+ remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
57
+ printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
58
+ -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
@@ -1,5 +1,5 @@
1
1
  import { Test } from "@nestjs/testing"
2
- import { mock } from "@starci/jest-preset"
2
+ import { builder, mock } from "@starci/jest-preset"
3
3
  import { Agent } from "undici"
4
4
  import { HttpErrorCode } from "./errors/http.error"
5
5
  import { FetchHttpClient } from "./fetch-http-client.client"
@@ -10,9 +10,9 @@ import type { HttpOptions } from "./http.options"
10
10
  const anyOf = (type: unknown): unknown => expect.any(type)
11
11
  const objectLike = (shape: Record<string, unknown>): unknown => expect.objectContaining(shape)
12
12
 
13
- const build = async (options: HttpOptions = {}): Promise<FetchHttpClient> => {
13
+ const build = async (overrides?: Partial<HttpOptions>): Promise<FetchHttpClient> => {
14
14
  const moduleRef = await Test.createTestingModule({
15
- providers: [FetchHttpClient, { provide: HTTP_OPTIONS, useValue: options }],
15
+ providers: [FetchHttpClient, { provide: HTTP_OPTIONS, useValue: builder<HttpOptions>({})(overrides) }],
16
16
  }).compile()
17
17
  return moduleRef.get(FetchHttpClient)
18
18
  }
@@ -65,9 +65,8 @@ export class FetchHttpClient implements HttpClient, OnModuleDestroy {
65
65
  method: request.method,
66
66
  headers: this.headersOf(request),
67
67
  ...(body === undefined ? {} : { body }),
68
- signal,
69
68
  }
70
- return await fetch(request.url, this.routed(init))
69
+ return await fetch(request.url, { ...this.routed(init), signal })
71
70
  } catch (cause) {
72
71
  throw this.failure(cause)
73
72
  }
@@ -1,6 +1,7 @@
1
1
  import type { EnvSource } from "@modules/platform/config"
2
2
  import { parseServerConfig } from "@modules/platform/config"
3
3
  import { parsePrimaryDatabaseConfig } from "@modules/platform/database"
4
+ import { parseHttpConfig } from "@modules/platform/http"
4
5
  import { parseHttpSecurityConfig } from "@modules/platform/http-security"
5
6
  import { parsePassportConfig } from "@modules/integrations/passport"
6
7
  import { parseObjectStorageConfig } from "@modules/integrations/object-storage"
@@ -11,6 +12,8 @@ export interface ApiEnv {
11
12
  readonly server: ReturnType<typeof parseServerConfig>
12
13
  /** The origin allowlist and rate limits. */
13
14
  readonly httpSecurity: ReturnType<typeof parseHttpSecurityConfig>
15
+ /** What the outbound HTTP client trusts beyond Node's bundled certificate authorities. */
16
+ readonly http: ReturnType<typeof parseHttpConfig>
14
17
  /** The least-privilege PostgreSQL connection to the app's own database. */
15
18
  readonly database: ReturnType<typeof parsePrimaryDatabaseConfig>
16
19
  /** Google OAuth, the app's own access tokens and the refresh cookie. */
@@ -23,6 +26,7 @@ export interface ApiEnv {
23
26
  export const readApiEnv = (env: EnvSource): ApiEnv => ({
24
27
  server: parseServerConfig(env),
25
28
  httpSecurity: parseHttpSecurityConfig(env),
29
+ http: parseHttpConfig(env),
26
30
  database: parsePrimaryDatabaseConfig(env),
27
31
  passport: parsePassportConfig(env),
28
32
  objectStorage: parseObjectStorageConfig(env),
@@ -2,6 +2,7 @@ import type { PassportOptions } from "@modules/integrations/passport"
2
2
  import type { ObjectStorageOptions } from "@modules/integrations/object-storage"
3
3
  import type { ServerOptions } from "@modules/platform/config"
4
4
  import type { DatabaseConnectionOptions } from "@modules/platform/database"
5
+ import type { HttpOptions } from "@modules/platform/http"
5
6
  import type { HttpSecurityOptions } from "@modules/platform/http-security"
6
7
 
7
8
  /** Everything the API needs from its environment, parsed by `api.env.ts` and handed to `AppModule.register`. */
@@ -10,6 +11,8 @@ export interface ApiOptions {
10
11
  readonly server: ServerOptions
11
12
  /** The origin allowlist and rate limits. */
12
13
  readonly httpSecurity: HttpSecurityOptions
14
+ /** What the outbound HTTP client trusts beyond Node's bundled certificate authorities. */
15
+ readonly http: HttpOptions
13
16
  /** The least-privilege PostgreSQL connection to the app's own database. */
14
17
  readonly database: DatabaseConnectionOptions
15
18
  /** Google OAuth, the app's own access tokens and the refresh cookie. */
@@ -25,7 +25,7 @@ export const platformModules = (options: ApiOptions): Array<DynamicModule> => [
25
25
  RetryModule.register({ isGlobal: true }),
26
26
  IdsModule.register({ isGlobal: true }),
27
27
  LoggingModule.register({ isGlobal: true }),
28
- HttpModule.register({ isGlobal: true }),
28
+ HttpModule.register({ isGlobal: true, ...options.http }),
29
29
  I18nModule.register({
30
30
  isGlobal: true,
31
31
  bundles: [HTTP_MESSAGES, ERRORS_MESSAGES, HTTP_SECURITY_MESSAGES, IDENTITY_MESSAGES],
@@ -13,6 +13,7 @@ export const buildWiringProof = () => ({
13
13
  rateLimit: { windowMs: 60000, defaultLimit: 100, strictLimit: 10 },
14
14
  webhooks: {},
15
15
  },
16
+ http: {},
16
17
  database: {
17
18
  name: "primary",
18
19
  provider: "postgres",