@starci/hfs 4.11.0 → 4.11.8

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 (123) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +40 -1
  3. package/db/drift.mjs +8 -6
  4. package/db/image.mjs +9 -4
  5. package/db/migrate.mjs +3 -3
  6. package/package.json +2 -1
  7. package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
  8. package/runtime/knowledge/hfs/rules.yaml +29 -12
  9. package/runtime/knowledge/hfs/slots.yaml +48 -37
  10. package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
  11. package/runtime/knowledge/patterns/be/queues.yaml +2 -3
  12. package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
  13. package/runtime/modules/kernel/failure-codes.yaml +17 -17
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
  15. package/runtime/scripts/gates/starcistacks.mjs +6 -2
  16. package/runtime/scripts/guards/push-scan.mjs +1 -1
  17. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  18. package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
  19. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  20. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  21. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  22. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  23. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  24. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  25. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  26. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  27. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  28. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  29. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  30. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  31. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  32. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  33. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  34. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  35. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  36. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  38. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  39. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  41. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
  42. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  43. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  44. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  45. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  46. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  47. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  48. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  49. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  50. package/runtime/scripts/hfs/secret-sops.mjs +93 -9
  51. package/runtime/scripts/hfs/secret-standard.mjs +315 -0
  52. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  53. package/runtime/scripts/hfs/secret.mjs +110 -46
  54. package/runtime/scripts/hfs/slots.mjs +8 -1
  55. package/runtime/scripts/hfs/stack-deploy-config.mjs +20 -6
  56. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  57. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  58. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  59. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  60. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  61. package/scaffold/add-inbox-lite.mjs +2 -2
  62. package/scaffold/app.mjs +2 -2
  63. package/src/main.mjs +5 -4
  64. package/stack/build.mjs +1 -1
  65. package/stack/config.mjs +2 -1
  66. package/stack/docker-transport.mjs +2 -1
  67. package/stack/remote.mjs +4 -3
  68. package/stack/sealed.mjs +9 -8
  69. package/stack/target-binding.mjs +2 -1
  70. package/sync/hygiene.mjs +1 -1
  71. package/sync/index.mjs +33 -10
  72. package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
  73. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  74. package/templates/app/gitignore +2 -1
  75. package/templates/app/gitignore-lite +3 -1
  76. package/templates/app/package-scripts/db.json +1 -1
  77. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
  78. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  79. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  80. package/templates/app/skeleton-lite/README.md +2 -2
  81. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  82. package/templates/be/inbox/events.json.tpl +1 -1
  83. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  84. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  85. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  86. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  87. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  88. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  89. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  90. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  91. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  92. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  93. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  94. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  95. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  96. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  97. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  98. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  99. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  100. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  101. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  102. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
  103. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
  104. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  105. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  106. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  107. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  108. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  109. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  110. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  111. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +37 -2
  112. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -3
  113. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  114. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  115. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  116. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  117. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  118. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  119. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  120. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  121. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  122. package/templates/app/db-image/db/Dockerfile +0 -8
  123. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import { claimFile } from "../api/fs/claim-file.mjs";
6
6
  import { isSopsEnvelope } from "../lib/sops-envelope.mjs";
7
7
  import { byCodeUnit } from "../lib/list.mjs";
8
+ import { LEGACY_SUFFIX, isLegacyName, isSealedName, memberFile } from "../lib/sealed-member.mjs";
8
9
  import {
9
10
  envelopeOf,
10
11
  plaintextOf,
@@ -50,21 +51,30 @@ export const fileOf = (directory, slug) => {
50
51
  throw new SecretError(
51
52
  `${slug === undefined ? "a secret name" : slug} is not a secret slug: lowercase letters, digits and dashes`,
52
53
  );
53
- return path.join(directory, `${slug}.enc`);
54
+ try { return memberFile(directory, slug); }
55
+ catch (error) { throw new SecretError(error.message); }
54
56
  };
55
57
 
56
- /** Build and validate a replacement envelope without publishing it. */
58
+ /**
59
+ * Build and validate a replacement envelope without publishing it. An existing member is decrypted only when it holds a key
60
+ * `values` does not replace; a member whose every key is replaced (the one-key member a `set` re-seals) needs no age identity.
61
+ */
57
62
  function sealSecretMap({ file, values, age, sops }) {
58
63
  let map = Object.create(null);
59
64
  let recipients = age;
65
+ const legacy = isLegacyName(path.basename(file));
60
66
  if (fs.existsSync(file)) {
61
67
  const envelope = envelopeOf(fs.readFileSync(file, "utf8"));
62
- map = sops.decrypt(file, envelope.format);
63
- if (!age.length) recipients = envelope.recipients;
68
+ if (!envelope.keys.every((key) => Object.hasOwn(values, key)))
69
+ map = sops.decrypt(file, envelope.format);
70
+ if (!age.length && legacy) recipients = envelope.recipients;
64
71
  }
65
72
  validateMap(map);
66
73
  map = { ...map, ...values };
67
- const sealed = sops.seal({ inputType: "dotenv", plaintext: plaintextOf("dotenv", map), recipients, filenameOverride: file });
74
+ // A member is sops YAML, its values double-quoted scalars kept byte for byte, sealed to the recipients its environment's
75
+ // .sops.yaml rule names (so a re-seal follows a recipients change); a legacy member stays dotenv to its own recipients.
76
+ const format = legacy ? "dotenv" : "yaml";
77
+ const sealed = sops.seal({ inputType: format, plaintext: plaintextOf(format, map), recipients, filenameOverride: file });
68
78
  let parsed;
69
79
  try { parsed = envelopeOf(sealed); }
70
80
  catch { throw new SecretError("sops returned an invalid sealed document"); }
@@ -112,14 +122,18 @@ export function writeSecret({ file, values, age, sops, write }) {
112
122
  } finally { held.release(); }
113
123
  }
114
124
 
115
- /** Update named keys through the same canonical sealed-secret owner used by the CLI. */
125
+ /**
126
+ * Update named keys through the same canonical sealed-secret owner used by the CLI. `legacy` creates a new member as a dotenv
127
+ * `.enc`, for the sops-free bootstrap sealer of a fresh scaffold, which writes only that format; `migrate` moves it later.
128
+ */
116
129
  export function setSecretValues(
117
130
  repoRoot,
118
131
  { slug, values, env = null },
119
- { sops = null, age = [], processEnv = process.env, write } = {},
132
+ { sops = null, age = [], processEnv = process.env, write, legacy = false } = {},
120
133
  ) {
121
134
  const directory = secretsDirectory(repoRoot, env);
122
- const file = fileOf(directory, slug);
135
+ const existing = fileOf(directory, slug);
136
+ const file = legacy && !fs.existsSync(existing) ? path.join(directory, `${slug}${LEGACY_SUFFIX}`) : existing;
123
137
  if (
124
138
  !values ||
125
139
  typeof values !== "object" ||
@@ -144,7 +158,7 @@ export function sealedFiles(directory) {
144
158
  return fs.existsSync(directory)
145
159
  ? fs
146
160
  .readdirSync(directory)
147
- .filter((name) => name.endsWith(".enc"))
161
+ .filter(isSealedName)
148
162
  .sort(byCodeUnit)
149
163
  : [];
150
164
  }
@@ -1,5 +1,7 @@
1
1
  // secret.mjs - app secret init|set|get|unset|rename|remove|materialize|list|verify: sealed custody and private KEY files.
2
2
  // SOPS plaintext travels on stdin and stays in memory; normal CLI output contains names and local file paths only.
3
+ // set seals one or more keys in one sops write: typed at a masked prompt on a terminal, KEY=value lines on redirected stdin
4
+ // (the whole stdin for one key), or --generate (scripts/hfs/secret-prompt.mjs).
3
5
  // Every verb runs at the app root or --cwd. Exit 0 done, 2 refusal; --reveal is the only plaintext stdout path.
4
6
  // unset, rename and remove edit one sealed member and are dry runs until --execute (scripts/hfs/secret-edit.mjs).
5
7
  // verify decrypts each member in memory and names only keys and violated shapes, never a value (R249).
@@ -7,19 +9,23 @@ import fs from "node:fs";
7
9
  import path from "node:path";
8
10
  import { randomBytes } from "node:crypto";
9
11
  import { invocationDir } from "../lib/roots.mjs";
10
- import { initializeSecrets } from "./secret-init.mjs";
11
- import { validateKey } from "./secret-document.mjs";
12
+ import { environmentRecipients, initializeSecrets, ownerRecipient } from "./secret-init.mjs";
13
+ import { declareKeys, groupMap, migrateMember, printAudit, splitMember } from "./secret-standard.mjs";
14
+ import { envelopeOf, validateKey } from "./secret-document.mjs";
12
15
  import { defaultSops } from "./secret-sops.mjs";
13
16
  import { fileOf, secretsDirectory, writeSecret } from "./secret-store.mjs";
14
17
  import { getSecret, listSecrets, materializeSecrets } from "./secret-output.mjs";
15
18
  import { SecretError, publicStorage, secretErrorMessage } from "./secret-errors.mjs";
16
19
  import { editSecret, validateEditNames } from "./secret-edit.mjs";
17
20
  import { verifySecrets } from "./rules/env-value-shape.mjs";
21
+ import { setValues } from "./secret-prompt.mjs";
18
22
 
19
23
  export { envelopeOf, formatOf, plaintextOf } from "./secret-document.mjs";
20
24
  export { setSecretValues } from "./secret-store.mjs";
21
25
 
22
- const VERBS = new Set(["init", "list", "get", "set", "unset", "rename", "remove", "materialize", "verify"]);
26
+ const VERBS = new Set(["init", "list", "get", "set", "unset", "rename", "remove", "materialize", "verify", "recipients", "migrate", "split", "audit"]);
27
+ const MOVE_VERBS = new Set(["migrate", "split"]);
28
+ const LIST_FLAGS = ["--add", "--remove", "--from-file", "--group"];
23
29
  const EDIT_VERBS = new Set(["unset", "rename", "remove"]);
24
30
 
25
31
  function valueOption(opts, flag, value) {
@@ -35,9 +41,13 @@ function parse(argv) {
35
41
  for (let i = 0; i < argv.length; i += 1) {
36
42
  const arg = argv[i];
37
43
  const [flag, inline] = arg.split(/=(.*)/su);
38
- if (["--cwd", "--generate"].includes(flag)) {
44
+ if (LIST_FLAGS.includes(flag)) {
45
+ const value = inline ?? argv[++i];
46
+ if (value === undefined || value.startsWith("--")) throw new SecretError(`${flag} needs a value`);
47
+ (opts[flag.slice(2)] ??= []).push(value);
48
+ } else if (["--cwd", "--generate", "--to", "--group-file"].includes(flag)) {
39
49
  valueOption(opts, flag, inline ?? argv[++i]);
40
- } else if (["--to-file", "--reveal", "--execute", "--force-nonempty"].includes(flag)) {
50
+ } else if (["--to-file", "--reveal", "--execute", "--force-nonempty", "--into-existing"].includes(flag)) {
41
51
  if (inline !== undefined && !["true", "false"].includes(inline))
42
52
  throw new SecretError(`${flag} expects true or false`);
43
53
  opts[flag.slice(2)] = inline !== "false";
@@ -48,27 +58,28 @@ function parse(argv) {
48
58
  return opts;
49
59
  }
50
60
 
51
- const readStdin = () => {
52
- if (process.stdin.isTTY)
53
- throw new SecretError(
54
- "set reads a value from redirected stdin; use --generate <bytes> to generate one",
55
- );
56
- return fs.readFileSync(0, "utf8");
57
- };
58
-
59
61
  function usageArguments(verb) {
60
62
  if (verb === "init") return "";
61
- if (verb === "set") return "<env> <slug> <KEY> [--generate <bytes>]";
63
+ if (verb === "set") return "<env> <slug> <KEY> [KEY ...] [--generate <bytes>]";
62
64
  if (verb === "get") return "<env> <slug> [KEY] --to-file [--reveal]";
63
65
  if (verb === "unset") return "<env> <slug> <KEY> [--execute]";
64
66
  if (verb === "rename") return "<env> <slug> <OLD> <NEW> [--execute]";
65
67
  if (verb === "remove") return "<env> <slug> [--execute] [--force-nonempty]";
68
+ if (verb === "recipients") return "<env> [--add <age1...|owner>] [--remove <age1...|owner>]";
69
+ if (verb === "migrate") return "<env> <slug> [--to <slug>] [--into-existing] [--execute]";
70
+ if (verb === "split") return "<env> <slug> [--group KEY=member ...] [--group-file <yaml>] [--into-existing] [--execute]";
71
+ if (verb === "audit") return "[<env>]";
66
72
  return "<env>";
67
73
  }
68
74
 
69
75
  function editOptionsFor(verb, opts) {
70
- if (opts.execute !== undefined && !EDIT_VERBS.has(verb))
71
- throw new SecretError("--execute is only valid for unset, rename and remove");
76
+ if (opts.execute !== undefined && !EDIT_VERBS.has(verb) && !MOVE_VERBS.has(verb))
77
+ throw new SecretError("--execute is only valid for unset, rename, remove, migrate and split");
78
+ if (opts.to !== undefined && verb !== "migrate") throw new SecretError("--to is only valid for migrate");
79
+ if ((opts.group || opts["group-file"] !== undefined) && verb !== "split") throw new SecretError("--group and --group-file are only valid for split");
80
+ if (opts["into-existing"] !== undefined && !MOVE_VERBS.has(verb)) throw new SecretError("--into-existing is only valid for migrate and split");
81
+ if ((opts.add || opts.remove) && verb !== "recipients") throw new SecretError("--add and --remove are only valid for recipients");
82
+ if (opts["from-file"] && verb !== "set") throw new SecretError("--from-file is only valid for set");
72
83
  if (opts["force-nonempty"] !== undefined && verb !== "remove")
73
84
  throw new SecretError("--force-nonempty is only valid for remove");
74
85
  }
@@ -90,11 +101,15 @@ function optionsFor(verb, opts) {
90
101
  list: [1, 1],
91
102
  materialize: [1, 1],
92
103
  verify: [1, 1],
93
- set: [3, 3],
104
+ set: [3, Infinity],
94
105
  get: [2, 3],
95
106
  unset: [3, 3],
96
107
  rename: [4, 4],
97
108
  remove: [2, 2],
109
+ recipients: [1, 1],
110
+ migrate: [2, 2],
111
+ split: [2, 2],
112
+ audit: [0, 1],
98
113
  }[verb];
99
114
  if (count < bounds[0] || count > bounds[1])
100
115
  throw new SecretError(
@@ -105,40 +120,71 @@ function optionsFor(verb, opts) {
105
120
  outputOptionsFor(verb, opts);
106
121
  editOptionsFor(verb, opts);
107
122
  if (EDIT_VERBS.has(verb)) validateEditNames(verb, opts.positional.slice(2));
108
- if (verb === "set") validateKey(opts.positional[2]);
123
+ if (verb === "set") {
124
+ const keys = opts.positional.slice(2);
125
+ for (const key of keys) validateKey(key);
126
+ const twice = keys.find((key, index) => keys.indexOf(key) !== index);
127
+ if (twice) throw new SecretError(`set names ${twice} twice`);
128
+ }
109
129
  if (verb === "get" && opts.positional[2] !== undefined)
110
130
  validateKey(opts.positional[2]);
111
131
  }
112
132
 
113
- function secretValue(opts, stdin, random) {
114
- let value;
115
- if (opts.generate !== undefined) {
116
- const bytes = Number(opts.generate);
117
- if (!Number.isInteger(bytes) || bytes < 1 || bytes > 1024)
118
- throw new SecretError(
119
- "--generate must be a whole byte count from 1 to 1024",
120
- );
121
- value = random(bytes).toString("base64url");
122
- } else {
123
- if (typeof stdin !== "function" && stdin?.isTTY)
124
- throw new SecretError("set requires redirected stdin");
125
- value = String(
126
- typeof stdin === "function"
127
- ? stdin()
128
- : fs.readFileSync(stdin?.fd ?? 0, "utf8"),
129
- ).replace(/\r?\n$/u, "");
133
+ /** The `--from-file KEY=path` values of set: each a named key, read byte for byte by setValues. */
134
+ function fromFiles(entries, keys) {
135
+ const files = {};
136
+ for (const entry of entries ?? []) {
137
+ const at = entry.indexOf("=");
138
+ const key = entry.slice(0, at);
139
+ if (at < 1 || !entry.slice(at + 1)) throw new SecretError("--from-file takes KEY=path");
140
+ if (!keys.includes(key)) throw new SecretError(`--from-file names ${key}, which this set does not`);
141
+ if (Object.hasOwn(files, key)) throw new SecretError(`--from-file names ${key} twice`);
142
+ files[key] = entry.slice(at + 1);
130
143
  }
131
- if (!value) throw new SecretError("the value on stdin is empty");
132
- return value;
144
+ return files;
133
145
  }
134
146
 
135
- /** All app secrets verbs; stdin, sops, random and tool ports permit isolated custody specs. */
136
- export function secretMain(
147
+ /** What `set` declared in the environment's stack `secrets:`, or the keys to declare by hand; nothing when all were declared. */
148
+ function declarationLine(environment, { declared, byHand }) {
149
+ if (declared.length) return `declared ${declared.join(", ")} in environments.${environment}.secrets of application-stacks.yaml\n`;
150
+ if (byHand.length) return `declare ${byHand.join(", ")} in environments.${environment}.secrets of application-stacks.yaml (this environment's shape is wired by hand)\n`;
151
+ return "";
152
+ }
153
+
154
+ /** The app root of a sealed member: `<root>/.starcistacks/<env>/secrets/<slug>.enc`. */
155
+ const appRootOf = (file) => path.resolve(path.dirname(file), "..", "..", "..");
156
+
157
+ /**
158
+ * The success line of set: the member and its keys and, for a member this set created, the age identity a later set needs to
159
+ * open it (the recipient it is sealed to, and where this machine holds that identity, or the fix when it holds none). Names
160
+ * and paths only.
161
+ */
162
+ function sealedLine(environment, slug, keys, file, created, seam) {
163
+ const head = `sealed ${environment}/${slug} (${keys.join(", ")})`;
164
+ if (!created) return `${head}\n`;
165
+ let recipients = [];
166
+ try { recipients = envelopeOf(fs.readFileSync(file, "utf8")).recipients; }
167
+ catch { recipients = []; }
168
+ const of = recipients.length ? ` of ${recipients.join(", ")}` : "";
169
+ let held = "";
170
+ if (seam.identity?.source) held = `; this machine holds it in ${seam.identity.source}`;
171
+ else if (seam.identity)
172
+ held = `; this machine holds none: add SOPS_AGE_KEY to ${path.join(appRootOf(file), ".claude", "secret.env")} before the next set`;
173
+ return `${head}; a new member: adding keys to it later needs the age identity${of}${held}\n`;
174
+ }
175
+
176
+ /**
177
+ * All app secrets verbs; stdin, prompt, sops, random and tool ports permit isolated custody specs. `hostIdentity`
178
+ * (`{ label, read }`) is the harness CLI's secret.env SOPS_AGE_KEY, the last place before keys.txt the age identity is looked for.
179
+ */
180
+ export async function secretMain(
137
181
  argv,
138
182
  {
139
183
  stdout = (s) => process.stdout.write(s),
140
184
  stderr = (s) => process.stderr.write(s),
141
- stdin = readStdin,
185
+ stdin,
186
+ prompt,
187
+ hostIdentity = null,
142
188
  sops = null,
143
189
  random = randomBytes,
144
190
  env = process.env,
@@ -151,24 +197,37 @@ export function secretMain(
151
197
  try {
152
198
  if (!VERBS.has(verb))
153
199
  throw new SecretError(
154
- "usage: starci app secret init | set <env> <slug> <KEY> | get <env> <slug> [KEY] --to-file | unset <env> <slug> <KEY> | rename <env> <slug> <OLD> <NEW> | remove <env> <slug> | materialize <env> | list <env> | verify <env>",
200
+ "usage: starci app secret init | set <env> <slug> <KEY> [KEY ...] | get <env> <slug> [KEY] --to-file | unset <env> <slug> <KEY> | rename <env> <slug> <OLD> <NEW> | remove <env> <slug> | materialize <env> | list <env> | verify <env> | recipients <env> | migrate <env> <slug> [--to <slug>] [--into-existing] | split <env> <slug> [--group KEY=member] [--group-file <yaml>] [--into-existing] | audit [<env>]",
155
201
  );
156
202
  const opts = parse(rest);
157
203
  optionsFor(verb, opts);
158
204
  const repoRoot = path.resolve(cwd, opts.cwd ?? ".");
159
205
  if (verb === "init")
160
206
  return publicStorage(() =>
161
- initializeSecrets(repoRoot, { env, platform, tools, stdout }),
207
+ initializeSecrets(repoRoot, { env, platform, tools, stdout, host: hostIdentity }),
208
+ );
209
+ if (verb === "audit") return printAudit(repoRoot, opts.positional[0] ?? null, stdout);
210
+ if (verb === "recipients")
211
+ return publicStorage(() =>
212
+ environmentRecipients(repoRoot, opts.positional[0], {
213
+ add: opts.add ?? [], remove: opts.remove ?? [], stdout,
214
+ owner: () => ownerRecipient(repoRoot, env, { platform, tools, host: hostIdentity }),
215
+ }),
162
216
  );
163
217
  const [environment, slug, key] = opts.positional;
164
218
  const directory = secretsDirectory(repoRoot, environment);
165
219
  if (verb === "list") return listSecrets(directory, stdout);
166
- const open = () => sops ?? defaultSops(env, repoRoot, platform, tools);
220
+ const open = () => sops ?? defaultSops(env, repoRoot, platform, tools, hostIdentity);
167
221
  if (EDIT_VERBS.has(verb))
168
222
  return editSecret(verb, {
169
223
  directory, environment, slug, names: opts.positional.slice(2),
170
224
  execute: opts.execute === true, force: opts["force-nonempty"] === true, sops: open, stdout,
171
225
  });
226
+ if (MOVE_VERBS.has(verb)) {
227
+ const move = { directory, environment, member: slug, to: opts.to, intoExisting: opts["into-existing"] === true, execute: opts.execute === true, sops: open, stdout };
228
+ if (verb === "migrate") return migrateMember(move);
229
+ return splitMember({ ...move, groups: groupMap(opts.group, opts["group-file"] === undefined ? null : path.resolve(cwd, opts["group-file"])) });
230
+ }
172
231
  const seam = open();
173
232
  if (verb === "verify")
174
233
  return verifySecrets({ repoRoot, directory, environment, sops: seam, stdout });
@@ -177,9 +236,14 @@ export function secretMain(
177
236
  return materializeSecrets(directory, seam, output);
178
237
  const file = fileOf(directory, slug);
179
238
  if (verb === "get") return getSecret(file, key, seam, opts, output, stderr);
180
- const value = secretValue(opts, stdin, random);
181
- writeSecret({ file, values: { [key]: value }, age: [], sops: seam });
182
- stdout(`sealed ${environment}/${slug} (${key})\n`);
239
+ const keys = opts.positional.slice(2);
240
+ const created = !fs.existsSync(file);
241
+ // Adding a key decrypts the member, so its identity is checked before any value is asked for; replacing every key does not.
242
+ if (!created && !envelopeOf(fs.readFileSync(file, "utf8")).keys.every((name) => keys.includes(name))) seam.requireIdentity?.(file);
243
+ const values = await setValues(keys, { generate: opts.generate, stdin, prompt, random, fromFile: fromFiles(opts["from-file"], keys) });
244
+ writeSecret({ file, values, age: [], sops: seam });
245
+ stdout(sealedLine(environment, slug, keys, file, created, seam));
246
+ stdout(declarationLine(environment, declareKeys(repoRoot, environment, slug, keys)));
183
247
  return 0;
184
248
  } catch (error) {
185
249
  const message = secretErrorMessage(error);
@@ -127,6 +127,13 @@ function sideDeclaration(manifest, declaration, name, edition, providers) {
127
127
  });
128
128
  }
129
129
 
130
+ /** R334: an app's bare database function name must be a pg built-in the canon lists bare (ruleParams.be.dbFunctions); any other is schema.name. */
131
+ function bareDbFunctionProblems(manifest, be) {
132
+ const builtins = new Set((manifest.ruleParams?.be?.dbFunctions ?? []).map((entry) => entry.name).filter((name) => !name.includes('.')));
133
+ return (be?.dbFunctions ?? []).filter((entry) => !entry.name.includes('.') && !builtins.has(entry.name))
134
+ .map((entry) => `sides.be.dbFunctions names ${entry.name} bare, but a bare name is only a pg built-in of the canon's list (ruleParams.be.dbFunctions); name a function of a migration schema-qualified (public.${entry.name})`);
135
+ }
136
+
130
137
  /**
131
138
  * A declaration checked against the manifest: kind app, the pinned major the manifest's (HFS_MANIFEST_MAJOR_MISMATCH otherwise,
132
139
  * and there is no compatibility window), and per side: app kinds of that profile, optionalSlots naming only opt-in slots of the
@@ -142,7 +149,7 @@ export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLA
142
149
  if (declaration.kind === RUNTIME_KIND) return runtimeDeclaration(manifest, declaration, file, side);
143
150
  const { edition, known, valid } = declarationEdition(manifest, declaration);
144
151
  if (!valid) fail('HFS_EDITION_INVALID', `hfs.json edition is ${JSON.stringify(declaration.edition)}; the editions this manifest knows are ${known.join(', ')} (absent means full)`, { file, edition: declaration.edition });
145
- const bad = PROFILES.flatMap((name) => sideProblems(manifest, name, declaration.sides[name], edition));
152
+ const bad = [...PROFILES.flatMap((name) => sideProblems(manifest, name, declaration.sides[name], edition)), ...bareDbFunctionProblems(manifest, declaration.sides.be)];
146
153
  if (bad.length) declarationInvalid(bad, file);
147
154
  if (side !== null && !PROFILES.includes(side)) fail('HFS_DECLARATION_INVALID', `${side} is not a side of an app (be, fe)`, { file, side });
148
155
  // A provider declared on any connection enables the provider slots of every profile (a fe side declares none): each view carries the union.
@@ -124,16 +124,30 @@ function grants(c, block, services, members) {
124
124
  return out;
125
125
  }
126
126
 
127
+ /**
128
+ * `deploy.secrets.settings`: one `{ member, keys }` or a list of them, one per member whose plain one-line values the stack
129
+ * interpolates; a key named by two entries is refused, so every setting has one source.
130
+ */
127
131
  function secretSettings(c, block, members) {
128
- if (block === undefined) return null;
129
- if (!closedFields(c, 'deploy.secrets.settings', block, FIELDS.settings)) return null;
130
- memberRef(c, 'deploy.secrets.settings.member', block.member, members);
131
- return { member: block.member, keys: stringList(c, 'deploy.secrets.settings.keys', block.keys, KEY, { nonEmpty: true }) };
132
+ if (block === undefined) return [];
133
+ const entries = Array.isArray(block) ? block : [block];
134
+ const seen = new Set();
135
+ return entries.flatMap((entry, index) => {
136
+ const at = Array.isArray(block) ? `deploy.secrets.settings[${index}]` : 'deploy.secrets.settings';
137
+ if (!closedFields(c, at, entry, FIELDS.settings)) return [];
138
+ memberRef(c, `${at}.member`, entry.member, members);
139
+ const keys = stringList(c, `${at}.keys`, entry.keys, KEY, { nonEmpty: true });
140
+ for (const key of keys) {
141
+ if (seen.has(key)) c.problem(`${at}.keys`, `${key} is named by another settings entry`);
142
+ seen.add(key);
143
+ }
144
+ return [{ member: entry.member, keys }];
145
+ });
132
146
  }
133
147
 
134
148
  function secrets(c, block, services, members) {
135
- if (block === undefined) return { grants: [], settings: null };
136
- if (!closedFields(c, 'deploy.secrets', block, FIELDS.secrets)) return { grants: [], settings: null };
149
+ if (block === undefined) return { grants: [], settings: [] };
150
+ if (!closedFields(c, 'deploy.secrets', block, FIELDS.secrets)) return { grants: [], settings: [] };
137
151
  if (block.grants !== undefined && !Array.isArray(block.grants)) c.problem('deploy.secrets.grants', 'must be a list');
138
152
  return { grants: grants(c, block.grants, services, members), settings: secretSettings(c, block.settings, members) };
139
153
  }
@@ -6,6 +6,7 @@ import {parseYaml} from '../../engine/yaml.mjs';
6
6
  import { validateAgainstSchema } from '../lib/json-schema.mjs';
7
7
  import { PROVISION_OPERATION_SCHEMA_REF, provisionOperationSchema } from '../lib/provision-operation-schema.mjs';
8
8
  import {slash} from '../lib/path-key.mjs';
9
+ import { envelopeOf } from './secret-document.mjs';
9
10
  import { isInside, walkFiles } from '../lib/walk.mjs';
10
11
  import { composeIncludePaths, swarmInventoryIssues, swarmModelIssues } from '../lib/swarm-stack.mjs';
11
12
  import { swarmHardeningIssues } from './rules/stacks-swarm.mjs';
@@ -204,6 +205,7 @@ const checkStackEntry=(entry,environmentNames,add,{stacksRoot,lite})=>{
204
205
  if(!entry.isDirectory()||entry.isSymbolicLink())return;
205
206
  if(entry.name==='tmp'){add('STACKS_SCRATCH_IN_CANONICAL',`${STACKS_DIR}/tmp`,'scratch belongs in the OS temp directory, not a tracked .starcistacks/tmp');return;}
206
207
  if(entry.name==='k8s')return;
208
+ if(lite&&entry.name==='images')return; // the managed db image (slot app.db.image, ruling lite-db-image-in-stacks), no environment
207
209
  if(lite&&liteCustodyDirectory(path.join(stacksRoot,entry.name)))return;
208
210
  if(!environmentNames.has(entry.name))add('STACKS_UNDECLARED_ENVIRONMENT',`${STACKS_DIR}/${entry.name}`,`${entry.name} is not an environment the declaration admits; either the declaration admits it or it is not an environment`);
209
211
  };
@@ -254,9 +256,14 @@ const checkSecret=(secret,at,{envDir,environment,root,tracked,secretNames,add})=
254
256
  if(!object(secret))return;
255
257
  if(secretNames.has(secret.name))add('secret-duplicate',`${at}.name`,'secret names must be unique');else secretNames.add(secret.name);
256
258
  const plain=nonempty(secret.where)?resolvedPath(envDir,secret.where):null;
257
- const enc=nonempty(secret.where)?resolvedPath(envDir,`${secret.where}.enc`):null;
259
+ // The member of `where` is `<where>.enc.yaml`, or a legacy `<where>.enc` (scripts/lib/sealed-member.mjs); an entry naming `key`
260
+ // declares that one key of the member, which its envelope must hold.
261
+ const member=nonempty(secret.where)?[`${secret.where}.enc.yaml`,`${secret.where}.enc`].map(name=>resolvedPath(envDir,name)).find(file=>file&&regular(file,{root:envDir})):null;
262
+ const enc=member??(nonempty(secret.where)?resolvedPath(envDir,`${secret.where}.enc.yaml`):null);
258
263
  if(!enc||!regular(enc,{root:envDir})||!sopsEnvelope(enc))
259
- add('encrypted-ref-invalid',`${at}.where`,'secret must resolve to <where>.enc as a recognizable SOPS envelope, an existing regular file below the environment directory');
264
+ add('encrypted-ref-invalid',`${at}.where`,'secret must resolve to <where>.enc.yaml (or a legacy <where>.enc) as a recognizable SOPS envelope, an existing regular file below the environment directory');
265
+ else if(nonempty(secret.key)&&!envelopeOf(fs.readFileSync(enc,'utf8')).keys.includes(secret.key))
266
+ add('encrypted-ref-invalid',`${at}.key`,`the member at ${secret.where} holds no ${secret.key}`);
260
267
  if(nonempty(secret.where)&&!plain)
261
268
  add('materialized-path-invalid',`${at}.where`,'secret plaintext path and its existing ancestors must remain below the environment directory without symlinks');
262
269
  if(!String(secret.recipientPolicy??'').trim()||!String(secret.keyCustody??'').trim())
@@ -56,6 +56,6 @@ export function liteCustodyDirectory(directory) {
56
56
  if (entry.isFile()) return entry.name === 'environment.json';
57
57
  if (!entry.isDirectory() || entry.name !== 'secrets') return false;
58
58
  return fs.readdirSync(path.join(directory, entry.name), { withFileTypes: true })
59
- .every(secret => secret.isFile() && !secret.isSymbolicLink() && (secret.name === '.gitkeep' || /^[a-z0-9-]+\.enc$/.test(secret.name)));
59
+ .every(secret => secret.isFile() && !secret.isSymbolicLink() && (secret.name === '.gitkeep' || /^[a-z0-9-]+\.enc(?:\.yaml)?$/.test(secret.name)));
60
60
  });
61
61
  }
@@ -0,0 +1,36 @@
1
+ // sealed-member.mjs - the file names of an app's sealed members (ruling app-secrets-one-standard): a member is
2
+ // `.starcistacks/<env>/secrets/<member>.enc.yaml`, a sops YAML document of one provider's keys; a legacy member is
3
+ // `<member>.enc`, a sops dotenv document, read until `starci app secret migrate` moves it. Every reader of the secrets
4
+ // folder names, lists and resolves members through this module.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ /** The suffix of a member, and of a legacy one. */
9
+ export const MEMBER_SUFFIX = '.enc.yaml';
10
+ export const LEGACY_SUFFIX = '.enc';
11
+
12
+ /** A sealed member's file name (either suffix), as a pattern for a name or the tail of a path. */
13
+ export const SEALED_NAME = /\.enc(?:\.yaml)?$/u;
14
+
15
+ /** True for the file name of a sealed member, current or legacy. */
16
+ export const isSealedName = (name) => SEALED_NAME.test(name);
17
+
18
+ /** True for the file name of a legacy (dotenv `.enc`) member. */
19
+ export const isLegacyName = (name) => name.endsWith(LEGACY_SUFFIX);
20
+
21
+ /** The member a sealed file name holds (`sepay.enc.yaml` and `sepay.enc` are both `sepay`). */
22
+ export const memberOfName = (name) => name.replace(SEALED_NAME, '');
23
+
24
+ /**
25
+ * The file of member `slug` in `directory`: the existing `.enc.yaml`, else the existing legacy `.enc`, else the `.enc.yaml` a new
26
+ * member is created as. Both present is refused: one member has one file.
27
+ */
28
+ export function memberFile(directory, slug) {
29
+ const current = path.join(directory, `${slug}${MEMBER_SUFFIX}`);
30
+ const legacy = path.join(directory, `${slug}${LEGACY_SUFFIX}`);
31
+ const hasCurrent = fs.existsSync(current);
32
+ if (hasCurrent && fs.existsSync(legacy))
33
+ throw new Error(`${slug} is sealed twice (${slug}${MEMBER_SUFFIX} and ${slug}${LEGACY_SUFFIX}); remove the one migrate replaced`);
34
+ if (hasCurrent) return current;
35
+ return fs.existsSync(legacy) ? legacy : current;
36
+ }
@@ -11,7 +11,7 @@ export const FORBIDDEN_FILES = [
11
11
  { name: 'private-key-file', test: (f) => /\.(pem|key|p12|pfx)$/i.test(f) || /(^|\/)id_(rsa|ed25519|ecdsa)$/i.test(f) },
12
12
  // .starcistacks/<stack>/secrets/ commits sops twins only (owner ruling push-scan-test-secrets-encrypted): a plaintext
13
13
  // credential there - a test password included - is git-ignored by the product repo and never pushed.
14
- { name: 'starcistacks-secret-plaintext', test: (f) => /(^|\/)\.starcistacks\/[^/]+\/secrets\//i.test(f) && !/(\.enc|\/\.gitkeep|\/KEYS\.md)$/i.test(f) },
14
+ { name: 'starcistacks-secret-plaintext', test: (f) => /(^|\/)\.starcistacks\/[^/]+\/secrets\//i.test(f) && !/(\.enc|\.enc\.yaml|\/\.gitkeep|\/KEYS\.md)$/i.test(f) },
15
15
  { name: 'credentials-json', test: (f) => /(^|\/)(credentials|service-account|client_secret)[^/]*\.json$/i.test(f) },
16
16
  ];
17
17
  // `word` in any case (name checks are case-aware by construction, not by flag).
@@ -51,7 +51,7 @@ export function swarmInventoryIssues({ components, lite = false }) {
51
51
  for (const [id, entry] of Object.entries(catalog)) {
52
52
  const component = mapping(entry);
53
53
  if (lite && present(component.image) && BACKING_IMAGE.test(repositoryOf(component.image)))
54
- add(`components.${id}.image`, 'lite runs no backing image; its db component runs the image db/Dockerfile builds, storage is the external S3 endpoint, identity the API');
54
+ add(`components.${id}.image`, 'lite runs no backing image; its db component runs the image .starcistacks/images/db/Dockerfile builds, storage is the external S3 endpoint, identity the API');
55
55
  jobDeclarationIssues(id, component, catalog, add);
56
56
  }
57
57
  return errors;
@@ -119,7 +119,7 @@ export function swarmModelIssues({ model, components, bindings, lite = false })
119
119
  else if (service.ports?.length && component.role !== 'ingress')
120
120
  add(`services.${id}.ports`, 'only a component with role ingress may publish Swarm ports');
121
121
  if (lite && present(service.image) && BACKING_IMAGE.test(repositoryOf(service.image)))
122
- add(`services.${id}.image`, 'lite stack runs no backing image; its db service runs the image db/Dockerfile builds');
122
+ add(`services.${id}.image`, 'lite stack runs no backing image; its db service runs the image .starcistacks/images/db/Dockerfile builds');
123
123
  jobServiceIssues(id, service, component, services, bound, add);
124
124
  }
125
125
  for (const id of Object.keys(bound)) {
@@ -102,7 +102,7 @@ const databasePatches = (root) => {
102
102
  ];
103
103
  };
104
104
 
105
- /** The first webhook installs only the lite inbox claim and transactional event publisher, shared by all providers. */
105
+ /** The first webhook installs only the lite inbox claim and the pgmq event bus, shared by all providers. */
106
106
  const handoffPlatformFiles = (root, values) => {
107
107
  const planned = [];
108
108
  for (const capability of ["inbox", "idempotency", "event-bus"]) {
@@ -229,7 +229,7 @@ export function addLiteWebhookInbox({
229
229
  ...handoffPlatformFiles(root, renderValues),
230
230
  ];
231
231
  const hasHandoffStorage = migrations.some((file) =>
232
- /create\s+table\s+public\.event_bus\b/i.test(fs.readFileSync(path.join(migrationsDir, file), "utf8")),
232
+ /create\s+function\s+public\.event_bus_send\b/i.test(fs.readFileSync(path.join(migrationsDir, file), "utf8")),
233
233
  );
234
234
  if (!hasHandoffStorage)
235
235
  planned[0].text = `${template("platform/migration.sql.tpl", renderValues)}\n${template("platform/client-operation-outcomes.sql.tpl", renderValues)}\n${planned[0].text}`;
package/scaffold/app.mjs CHANGED
@@ -145,7 +145,7 @@ const STARTER_DEPENDENCIES = Object.freeze({
145
145
  dependencies: {
146
146
  '@apollo/server': null, '@nestjs/apollo': null, '@nestjs/common': null, '@nestjs/core': null, '@nestjs/cqrs': null, '@nestjs/graphql': null,
147
147
  '@nestjs/platform-express': null, '@nestjs/typeorm': null, 'nest-commander': null, pg: null, graphql: null,
148
- 'class-transformer': null, 'class-validator': null, 'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null, zod: null,
148
+ 'class-transformer': null, 'class-validator': null, 'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null, undici: null, zod: null,
149
149
  },
150
150
  devDependencies: {
151
151
  turbo: null, '@nestjs/testing': null, '@starci/eslint-canon-be': null, '@starci/eslint-canon-fe': null, '@starci/jest-preset': null,
@@ -167,7 +167,7 @@ const LITE_STARTER_DEPENDENCIES = Object.freeze({
167
167
  '@nestjs/platform-express': null, '@nestjs/typeorm': null, '@nestjs/passport': null, passport: null, 'passport-google-oauth20': null, 'passport-local': null,
168
168
  '@aws-sdk/client-s3': null, '@aws-sdk/s3-request-presigner': null, bcryptjs: null,
169
169
  'class-transformer': null, 'class-validator': null, graphql: null, jose: null, pg: null,
170
- 'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null, zod: null,
170
+ 'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null, undici: null, zod: null,
171
171
  },
172
172
  devDependencies: {
173
173
  turbo: null, '@nestjs/testing': null, '@starci/eslint-canon-be': null, '@starci/eslint-canon-fe': null,
package/src/main.mjs CHANGED
@@ -108,7 +108,7 @@ starci app codemod button-intent [--write] [--format json|markdown] [--report <f
108
108
  starci app sync (--check | --write) [--cwd <dir>]
109
109
  starci app check [--cwd <dir>] [--json] [--fast] [--base <ref>] [--wiring] [--coverage-from <lcov>]
110
110
  starci app db new <kebab-name> [--json] [--cwd <dir>]
111
- starci app db baseline [--stamp <version>] [--url-env <NAME>] [--apply] [--cwd <dir>]
111
+ starci app db baseline [--stamp <version>] [--url-env <NAME>] [--squash [--drop-extension <a,b>] [--drop-pgmq-queue <q,job_*>] [--seed-tables <t1,t2>] [--image <ref>]] [--apply] [--cwd <dir>]
112
112
  starci app db restamp --map <restamp.yaml> [--url-env <NAME>] [--apply] [--cwd <dir>]
113
113
  starci app db plan [--url-env <NAME>] [--json] [--cwd <dir>]
114
114
  starci app db dump [--url-env <NAME>] --out <dir> [--schema-only] [--json] [--cwd <dir>]
@@ -460,11 +460,12 @@ function errorMessage(error) {
460
460
  /**
461
461
  * `harness` ({ version }) is the CLI that runs this package in-process: its version is the starci-harness an app is judged and
462
462
  * scaffolded at (runtime/scripts/hfs/canon-pins.mjs). `run` injects the database runner, `runLinter` the linter transport, `checkApp`
463
- * the HFS check and `stackIo` Swarm tools; production defaults execute every check.
463
+ * the HFS check and `stackIo` Swarm tools; production defaults execute every check. `hostIdentity` ({ label, read }) is the
464
+ * harness CLI's own secret.env SOPS_AGE_KEY, which `secret` reads last for the age identity; `prompt` replaces its masked prompt.
464
465
  */
465
466
  export async function main(argv, {
466
467
  cwd: baseCwd = process.cwd(), harness, stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), presets, prettier, lock, run, stackMain,
467
- env = process.env, stdin, sops, tools, platform, stackIo, runLinter, checkApp,
468
+ env = process.env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp,
468
469
  } = {}) {
469
470
  try {
470
471
  const global = globalOptions(argv, path.resolve(baseCwd));
@@ -472,7 +473,7 @@ export async function main(argv, {
472
473
  const out = global.quiet ? () => {} : stdout;
473
474
  if (global.help) { out(USAGE); return 0; }
474
475
  if (!VERBS.includes(verb)) { stderr(USAGE); return 2; }
475
- return await dispatchMain(verb, rest, { cwd: global.cwd, harness, edition: global.edition, stdout: out, stderr, presets, prettier, lock, run, stackMain, env, stdin, sops, tools, platform, stackIo, runLinter, checkApp });
476
+ return await dispatchMain(verb, rest, { cwd: global.cwd, harness, edition: global.edition, stdout: out, stderr, presets, prettier, lock, run, stackMain, env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp });
476
477
  } catch (error) {
477
478
  stderr(errorMessage(error));
478
479
  return 2;
package/stack/build.mjs CHANGED
@@ -45,7 +45,7 @@ function ownerOf(ctx, config) {
45
45
  return match[1].toLowerCase();
46
46
  }
47
47
 
48
- /** The Dockerfile of an app: be/apps/<app>/Dockerfile or fe/apps/<app>/Dockerfile, or db/Dockerfile for the db image; ambiguity and absence are refusals. */
48
+ /** The Dockerfile of an app: be/apps/<app>/Dockerfile or fe/apps/<app>/Dockerfile, or the managed .starcistacks/images/db/Dockerfile for the db image; ambiguity and absence are refusals. */
49
49
  function dockerfileOf(config, app) {
50
50
  if (app === DB_IMAGE_APP && fs.existsSync(path.join(config.repoRoot, DB_DOCKERFILE))) return DB_DOCKERFILE;
51
51
  const found = SIDES.map((side) => `${side}/apps/${app}/Dockerfile`).filter((file) => fs.existsSync(path.join(config.repoRoot, file)));
package/stack/config.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // config.mjs - loads `environments.<env>.deploy` of .starcistacks/application-stacks.yaml for the stack verbs.
2
2
  // The block is read and judged by the same module `starci app check` uses (runtime/scripts/hfs/stack-deploy-config.mjs).
3
3
  import fs from 'node:fs';
4
+ import { isSealedName, memberOfName } from '../runtime/scripts/lib/sealed-member.mjs';
4
5
  import path from 'node:path';
5
6
  import { parseYaml } from '../runtime/engine/yaml.mjs';
6
7
  import { DEPLOY_CONFIG_CODE, NAME, normalizeDeploy } from '../runtime/scripts/hfs/stack-deploy-config.mjs';
@@ -22,7 +23,7 @@ const projectOf = (repoRoot) => {
22
23
  };
23
24
 
24
25
  const sealedMembers = (secretsDir) => {
25
- try { return new Set(fs.readdirSync(secretsDir).filter((name) => name.endsWith('.enc')).map((name) => name.slice(0, -4))); } catch { return new Set(); }
26
+ try { return new Set(fs.readdirSync(secretsDir).filter(isSealedName).map(memberOfName)); } catch { return new Set(); }
26
27
  };
27
28
 
28
29
  const stackServices = (stackFile) => {
@@ -4,6 +4,7 @@
4
4
  // an explicit environment are affected (the minimal environment of `docker stack config` stays exactly as the render built it). The key lives in
5
5
  // a private temp directory that is overwritten and removed when the verb ends; it is never in argv, a line or an error.
6
6
  import fs from 'node:fs';
7
+ import { memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
7
8
  import path from 'node:path';
8
9
  import { writePrivate } from './private-file.mjs';
9
10
  import { openSealedSession } from './remote.mjs';
@@ -21,7 +22,7 @@ function needed(ctx, config, sshCmd) {
21
22
  if (sshCmd || ctx.env.DOCKER_HOST || ctx.env.GITHUB_ACTIONS === 'true' || ctx.env.STARCI_STACK_SEALED_SSH === '0') return false;
22
23
  const { sealed } = config.remote;
23
24
  if (!sealed) return false;
24
- return [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(path.join(config.secretsDir, `${slug}.enc`)));
25
+ return [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(memberFile(config.secretsDir, slug)));
25
26
  }
26
27
 
27
28
  function sshConfig(session, controlPath, windows) {