@starci/hfs 4.11.8 → 4.11.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +31 -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 +2 -2
  6. package/runtime/knowledge/hfs/slots.yaml +16 -1
  7. package/runtime/modules/kernel/failure-codes.yaml +5 -5
  8. package/runtime/scripts/api/sops/lib.mjs +30 -0
  9. package/runtime/scripts/api/sops/update-keys.mjs +20 -0
  10. package/runtime/scripts/hfs/path-findings.mjs +4 -2
  11. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
  12. package/runtime/scripts/hfs/secret-sops.mjs +15 -0
  13. package/runtime/scripts/hfs/secret-standard.mjs +54 -0
  14. package/runtime/scripts/hfs/secret.mjs +36 -6
  15. package/runtime/scripts/hfs/stack-deploy-config.mjs +11 -4
  16. package/scaffold/add-inbox-lite.mjs +6 -10
  17. package/scaffold/add-queue-lite.mjs +7 -2
  18. package/scaffold/add-table.mjs +5 -0
  19. package/scaffold/app.mjs +15 -3
  20. package/scaffold/lite-exports.mjs +23 -0
  21. package/src/main.mjs +1 -0
  22. package/stack/age-install.mjs +4 -3
  23. package/stack/context.mjs +32 -2
  24. package/stack/remote.mjs +4 -2
  25. package/stack/server-key.mjs +296 -0
  26. package/stack/ssh-session.mjs +7 -1
  27. package/sync/index.mjs +3 -0
  28. package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
  29. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
  30. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
  31. package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
  32. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
  33. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
  34. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +3 -3
  35. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +1 -2
  36. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  37. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  38. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
  39. package/templates/be/skeleton-lite/src/modules/platform/database/database.options.ts +4 -1
  40. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +4 -1
  41. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +0 -2
  42. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -0
  43. package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/index.ts +4 -0
  44. package/templates/be/skeleton-lite/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
  45. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +260 -0
  46. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +133 -0
  47. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
@@ -84,12 +84,14 @@ const DATABASE_ERROR = 'be/src/modules/platform/database/errors/database.error.t
84
84
 
85
85
  /** Skeleton files the lite starter leaves out and the queue capabilities use, each from its skeleton template. */
86
86
  const SKELETON_FILES = Object.freeze([
87
+ ['be/src/modules/platform/database/database.client.ts', 'skeleton-lite/src/modules/platform/database/database.client.ts'],
88
+ ['be/src/modules/platform/database/database.client.spec.ts', 'skeleton-lite/src/modules/platform/database/database.client.spec.ts'],
87
89
  ['be/src/modules/platform/database/database.sql.ts', 'skeleton-lite/src/modules/platform/database/database.sql.ts'],
88
90
  ['be/src/modules/platform/primitives/sequence.policy.ts', 'skeleton/src/modules/platform/primitives/sequence.policy.ts'],
89
91
  ['be/src/modules/platform/primitives/sequence.policy.spec.ts', 'skeleton/src/modules/platform/primitives/sequence.policy.spec.ts'],
90
92
  ]);
91
93
 
92
- /** The sql tag of the outbox and claim statements (with its identifier error) and the in-order relay the queue adapters walk their rows with. */
94
+ /** The database-function call and the sql tag of the outbox and claim statements (with its identifier error) and the in-order relay the queue adapters walk their rows with. */
93
95
  function skeletonFiles(repoRoot) {
94
96
  const created = SKELETON_FILES.filter(([relative]) => !fs.existsSync(path.join(repoRoot, ...relative.split('/'))))
95
97
  .map(([relative, source]) => ({ relative, text: render(template(`be/${source}`), {}) }));
@@ -364,7 +366,10 @@ export function planLiteQueue({ repoRoot, repo, noun, patterns, now }) {
364
366
  fs.writeFileSync(target, entry.text);
365
367
  }
366
368
  for (const patch of patches) fs.writeFileSync(patch.file, patch.text);
367
- const exported = registerLiteExports({ root: repoRoot, generator: 'queue' });
369
+ const exported = [
370
+ ...registerLiteExports({ root: repoRoot, generator: 'queue' }),
371
+ ...(patterns.includes('fenced-job') ? registerLiteExports({ root: repoRoot, generator: 'jobs' }) : []),
372
+ ];
368
373
  const sealed = sealRedisMembers(repoRoot, stack.environments);
369
374
  const changed = patches.map((patch) => path.relative(repoRoot, patch.file).split(path.sep).join('/'));
370
375
  return [...new Set([...created.map((entry) => entry.relative), ...changed, ...exported, ...sealed])];
@@ -472,9 +472,14 @@ function writeTableFiles({ root, planned, edit }, created) {
472
472
 
473
473
  }
474
474
 
475
+ /** The lite sql helpers of platform/database (requireOwnedEntity, the bounds and the sql tag). */
476
+ const DATABASE_SQL_FILE = "be/src/modules/platform/database/database.sql.ts";
477
+
475
478
  function tableRegistrations({ root, repo, fe, edit }) {
476
479
  const errorFile = "be/src/modules/platform/database/errors/database.error.ts";
477
480
  if (repo.edition === "lite" && fs.existsSync(at(root, errorFile))) edit(errorFile, withSqlIdentifierError);
481
+ // requireOwnedEntity lives in the sql helpers the lite starter leaves out; the first table writes them.
482
+ if (repo.edition === "lite") edit(DATABASE_SQL_FILE, (text) => text || render(fs.readFileSync(path.join(TEMPLATES_DIR, "be", "skeleton-lite", "src", "modules", "platform", "database", "database.sql.ts"), "utf8"), {}));
478
483
  const registrations = [
479
484
  ...(repo.edition === "lite" ? ["table"] : []),
480
485
  ...(fe ? ["api", "graphql"] : []),
package/scaffold/app.mjs CHANGED
@@ -353,13 +353,25 @@ const CLI_APP_DIR = 'apps/cli/';
353
353
  const declaresCliApp = app => app.sides.be.apps.some(entry => entry.kind === 'cli' && entry.name === 'cli');
354
354
 
355
355
  const LITE_APP_REUSED = new Set(['.editorconfig', '.gitattributes', '.nvmrc']);
356
+ /**
357
+ * The lite be files a generator writes when it first needs them, never the starter: the cli's runners, the in-order relay, and the
358
+ * raw SQL helpers of platform/database (the sql tag, and the database-function call statement with its spec), which only the table,
359
+ * queue, jobs and webhook capabilities call; a fresh app reads and writes through entities alone (R334).
360
+ */
356
361
  const LITE_DEFERRED_GENERATOR_FILES = new Set([
362
+ 'src/modules/platform/database/database.client.ts',
363
+ 'src/modules/platform/database/database.client.spec.ts',
364
+ 'src/modules/platform/database/database.sql.ts',
357
365
  'src/modules/platform/database/migration-runner.service.ts',
358
366
  'src/modules/platform/database/seed-runner.service.ts',
359
367
  'src/modules/platform/primitives/sequence.policy.ts',
360
368
  ]);
361
- /** The TypeORM schema files of a platform capability: lite's schema authority is db/migrations (be.persistence lite overlay). */
362
- const LITE_TYPEORM_SCHEMA = /^src\/modules\/platform\/[^/]+\/persistence\/(?:connection\.ts$|entities\/|migrations\/)/;
369
+ /**
370
+ * The persistence folder of a full platform capability (TypeORM migrations, connection arrays, `sql`-tagged statements and rows):
371
+ * lite's schema authority is db/migrations and its code reads and writes through entities (be.persistence lite overlay, R334), so a
372
+ * lite platform capability takes its persistence/ from the lite overlay alone.
373
+ */
374
+ const FULL_PLATFORM_PERSISTENCE = /^src\/modules\/platform\/[^/]+\/persistence\//;
363
375
  const liteBaseSource = (rel) => !rel.startsWith('apps/cli/')
364
376
  && !rel.startsWith('src/features/cli/')
365
377
  && !rel.startsWith('src/modules/domain/note/')
@@ -367,7 +379,7 @@ const liteBaseSource = (rel) => !rel.startsWith('apps/cli/')
367
379
  && rel !== 'src/modules/platform/database/migrate-connections.client.ts'
368
380
  && rel !== 'src/modules/platform/database/seed-connections.client.ts'
369
381
  && !LITE_DEFERRED_GENERATOR_FILES.has(rel)
370
- && !LITE_TYPEORM_SCHEMA.test(rel)
382
+ && !FULL_PLATFORM_PERSISTENCE.test(rel)
371
383
  && !rel.startsWith('src/tests/');
372
384
  /** The be files the lite overlay replaces: a base unit spec of such a file tests the full variant, so lite carries its own. */
373
385
  const liteOverlaid = (() => {
@@ -51,6 +51,22 @@ const REGISTRATIONS = Object.freeze({
51
51
  names: ["sql"],
52
52
  beforeSource: "./errors/database.error",
53
53
  },
54
+ {
55
+ // The outbox write and the relay call the queue functions of their migration (R334).
56
+ file: "be/src/modules/platform/database/index.ts",
57
+ source: "./database.client",
58
+ names: ["callDbFunction"],
59
+ beforeSource: "./primary.decorators",
60
+ },
61
+ ],
62
+ // The jobs capability (add job, the fenced-job pattern): its tick takes a transaction-scoped lock before it claims the minute (R335).
63
+ jobs: [
64
+ {
65
+ file: "be/src/modules/platform/database/index.ts",
66
+ source: "./database.client",
67
+ names: ["advisoryXactLock", "callDbFunction"],
68
+ beforeSource: "./primary.decorators",
69
+ },
54
70
  ],
55
71
  webhook: [
56
72
  {
@@ -59,6 +75,13 @@ const REGISTRATIONS = Object.freeze({
59
75
  names: ["BATCH_ROWS", "sql"],
60
76
  beforeSource: "./errors/database.error",
61
77
  },
78
+ {
79
+ // The inbox claim and the lite event bus call the functions of their migration (R334).
80
+ file: "be/src/modules/platform/database/index.ts",
81
+ source: "./database.client",
82
+ names: ["callDbFunction"],
83
+ beforeSource: "./primary.decorators",
84
+ },
62
85
  {
63
86
  // PRIMARY_ENTITY_MANAGER: the token the inbox service's unit spec provides its EntityManager double under.
64
87
  file: "be/src/modules/platform/database/index.ts",
package/src/main.mjs CHANGED
@@ -135,6 +135,7 @@ starci app secret remove <env> <slug> [--execute] [--force-nonempty] [--cwd <dir
135
135
  starci app secret materialize <env> [--cwd <dir>]
136
136
  starci app secret list <env> [--cwd <dir>]
137
137
  starci app secret verify <env> [--cwd <dir>]
138
+ starci app secret server-key <env> [--rotate | --backup github | --restore github] [--execute] [--cwd <dir>]
138
139
  starci app ci secret set <NAME> [--env E | --dependabot] [--repo owner/name] [--cwd <dir>]
139
140
  starci app ci var set <NAME> [--env E] [--value v] [--repo owner/name] [--cwd <dir>]
140
141
  starci app ci var sync [--execute] [--repo owner/name] [--cwd <dir>]
@@ -6,7 +6,8 @@ const AGE_VERSION = 'v1.2.1';
6
6
  const AGE_URL = `https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-amd64.tar.gz`;
7
7
  /** sha256 of the official linux/amd64 archive of AGE_VERSION. */
8
8
  const AGE_SHA256 = '7df45a6cc87d4da11cc03a539a7470c15b1041ab2b396af088fe9990f7c79d50';
9
- const INSTALL = { dnf: 'sudo -n dnf install -y age', yum: 'sudo -n yum install -y age', apk: 'sudo -n apk add age', 'apt-get': 'sudo -n apt-get install -y age' };
9
+ /** The install command of each package manager installAge detects; a refusal names them for a host it cannot install on. */
10
+ export const AGE_INSTALL = { dnf: 'sudo -n dnf install -y age', yum: 'sudo -n yum install -y age', apk: 'sudo -n apk add age', 'apt-get': 'sudo -n apt-get install -y age' };
10
11
 
11
12
  const STATIC_INSTALL = [
12
13
  'set -e',
@@ -26,8 +27,8 @@ export function installAge(run, report) {
26
27
  if (present(run)) return;
27
28
  const manager = packageManager(run);
28
29
  let failure = 'no package manager (dnf, yum, apk, apt-get) found';
29
- if (Object.hasOwn(INSTALL, manager)) {
30
- const packaged = run(INSTALL[manager]);
30
+ if (Object.hasOwn(AGE_INSTALL, manager)) {
31
+ const packaged = run(AGE_INSTALL[manager]);
31
32
  if (packaged.status === 0 && present(run)) { report.note(`installed age with ${manager}`); return; }
32
33
  failure = `${manager} could not install age: ${firstLine(packaged.stderr)}`;
33
34
  }
package/stack/context.mjs CHANGED
@@ -17,6 +17,34 @@ const realSpawn = (command, args, options = {}) => {
17
17
  return { status: run.status, stdout: run.stdout ?? '', stderr: run.stderr ?? '', error: run.error };
18
18
  };
19
19
 
20
+ /** Bytes of a stream's stderr a pipe keeps, for the first line of a failure. */
21
+ const STDERR_KEPT = 4096;
22
+
23
+ /**
24
+ * Streams `source`'s stdout into `sink`'s stdin, neither through a shell, and resolves { source, sink, stderr } (exit codes and
25
+ * the first stderr line of a failure). The bytes pass through the OS pipe only: they are never collected, logged or written to disk.
26
+ */
27
+ const realPipe = (source, sink) => new Promise((resolve) => {
28
+ const options = (end, stdio) => ({ env: end.env, cwd: end.cwd, stdio, windowsHide: true });
29
+ const from = spawn(source.command, source.args, options(source, ['ignore', 'pipe', 'pipe']));
30
+ const to = spawn(sink.command, sink.args, options(sink, ['pipe', 'ignore', 'pipe']));
31
+ const codes = {};
32
+ let stderr = '';
33
+ const keep = (chunk) => { if (stderr.length < STDERR_KEPT) stderr += String(chunk).slice(0, STDERR_KEPT - stderr.length); };
34
+ from.stderr.on('data', keep);
35
+ to.stderr.on('data', keep);
36
+ to.stdin.on('error', () => {});
37
+ from.stdout.pipe(to.stdin);
38
+ const settle = (name) => (code) => {
39
+ codes[name] = code ?? 1;
40
+ if ('source' in codes && 'sink' in codes) resolve({ ...codes, stderr: stderr.trim().split(/\r?\n/u)[0] ?? '' });
41
+ };
42
+ from.on('error', () => { from.stdout.unpipe(to.stdin); to.stdin.end(); settle('source')(127); });
43
+ to.on('error', () => settle('sink')(127));
44
+ from.on('close', settle('source'));
45
+ to.on('close', settle('sink'));
46
+ });
47
+
20
48
  const realSleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
21
49
 
22
50
  /**
@@ -28,7 +56,8 @@ const realSleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
28
56
  * sleep, now await sleep(ms); now() -> epoch ms
29
57
  * stdout, stderr text writers
30
58
  * fetch the HTTP seam of verify (url, init) -> Response
31
- * sops the secret seam (decrypt(file, format) -> map; decryptBinary(file) -> text); `io.sops` or, on first use, the app's own sops/age tools
59
+ * pipe (source, sink) -> Promise<{ source, sink, stderr }>: one process's stdout streamed into another's stdin (realPipe)
60
+ * sops the secret seam (decrypt(file, format) -> map; decryptBinary(file) -> text); `io.sops`, else on first use `io.openSops()` or the app's own sops/age tools
32
61
  */
33
62
  export function stackContext(io = {}) {
34
63
  const cwd = io.cwd ?? process.cwd();
@@ -46,9 +75,10 @@ export function stackContext(io = {}) {
46
75
  stdout: io.stdout ?? ((text) => process.stdout.write(text)),
47
76
  stderr: io.stderr ?? ((text) => process.stderr.write(text)),
48
77
  fetch: io.fetch ?? globalThis.fetch,
78
+ pipe: io.pipe ?? realPipe,
49
79
  /** The secret seam: the app's own sops/age tools, created on first use so verbs that open no custody never need them. */
50
80
  get sops() {
51
- sops ??= defaultSops(env, cwd);
81
+ sops ??= io.openSops?.() ?? defaultSops(env, cwd);
52
82
  return sops;
53
83
  },
54
84
  });
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