@starci/hfs 4.11.0 → 4.11.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +43 -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 +30 -13
  9. package/runtime/knowledge/hfs/slots.yaml +63 -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/api/sops/lib.mjs +30 -0
  16. package/runtime/scripts/api/sops/update-keys.mjs +20 -0
  17. package/runtime/scripts/gates/starcistacks.mjs +6 -2
  18. package/runtime/scripts/guards/push-scan.mjs +1 -1
  19. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  20. package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
  21. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  22. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  23. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  24. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  25. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  26. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  27. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  28. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  29. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  30. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  31. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  32. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  33. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  34. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  35. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
  36. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  38. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  39. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  41. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  43. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  44. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
  45. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  46. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  47. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  48. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  49. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  50. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  51. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  52. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  53. package/runtime/scripts/hfs/secret-sops.mjs +108 -9
  54. package/runtime/scripts/hfs/secret-standard.mjs +369 -0
  55. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  56. package/runtime/scripts/hfs/secret.mjs +140 -46
  57. package/runtime/scripts/hfs/slots.mjs +8 -1
  58. package/runtime/scripts/hfs/stack-deploy-config.mjs +31 -10
  59. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  60. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  61. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  62. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  63. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  64. package/scaffold/add-inbox-lite.mjs +2 -2
  65. package/scaffold/app.mjs +2 -2
  66. package/src/main.mjs +6 -4
  67. package/stack/age-install.mjs +4 -3
  68. package/stack/build.mjs +1 -1
  69. package/stack/config.mjs +2 -1
  70. package/stack/context.mjs +32 -2
  71. package/stack/docker-transport.mjs +2 -1
  72. package/stack/remote.mjs +8 -5
  73. package/stack/sealed.mjs +9 -8
  74. package/stack/server-key.mjs +296 -0
  75. package/stack/ssh-session.mjs +7 -1
  76. package/stack/target-binding.mjs +2 -1
  77. package/sync/hygiene.mjs +1 -1
  78. package/sync/index.mjs +36 -10
  79. package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
  80. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
  81. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
  82. package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
  83. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
  84. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
  85. package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
  86. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  87. package/templates/app/gitignore +2 -1
  88. package/templates/app/gitignore-lite +3 -1
  89. package/templates/app/package-scripts/db.json +1 -1
  90. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
  91. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  92. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  93. package/templates/app/skeleton-lite/README.md +2 -2
  94. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  95. package/templates/be/inbox/events.json.tpl +1 -1
  96. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  97. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  98. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  99. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  100. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  101. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  102. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  103. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  104. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  105. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  106. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  107. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  108. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  109. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  110. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  111. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  112. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  113. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  114. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  115. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
  116. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
  117. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  118. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  119. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  120. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  121. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  122. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  123. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  124. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +38 -3
  125. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -4
  126. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  127. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  128. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  129. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  130. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  131. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  132. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  133. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
  134. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  135. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  136. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  137. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
  138. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  139. package/templates/app/db-image/db/Dockerfile +0 -8
  140. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
@@ -1,25 +1,35 @@
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).
8
+ // server-key makes the deploy server's own age identity on the server and seals the environment to it (stack/server-key.mjs).
6
9
  import fs from "node:fs";
7
10
  import path from "node:path";
8
11
  import { randomBytes } from "node:crypto";
9
12
  import { invocationDir } from "../lib/roots.mjs";
10
- import { initializeSecrets } from "./secret-init.mjs";
11
- import { validateKey } from "./secret-document.mjs";
13
+ import { environmentRecipients, initializeSecrets, ownerRecipient } from "./secret-init.mjs";
14
+ import { declareKeys, groupMap, migrateMember, printAudit, splitMember } from "./secret-standard.mjs";
15
+ import { envelopeOf, validateKey } from "./secret-document.mjs";
12
16
  import { defaultSops } from "./secret-sops.mjs";
13
17
  import { fileOf, secretsDirectory, writeSecret } from "./secret-store.mjs";
14
18
  import { getSecret, listSecrets, materializeSecrets } from "./secret-output.mjs";
15
19
  import { SecretError, publicStorage, secretErrorMessage } from "./secret-errors.mjs";
16
20
  import { editSecret, validateEditNames } from "./secret-edit.mjs";
17
21
  import { verifySecrets } from "./rules/env-value-shape.mjs";
22
+ import { setValues } from "./secret-prompt.mjs";
23
+ import { stackContext } from "../../../stack/context.mjs";
24
+ import { StackError } from "../../../stack/errors.mjs";
25
+ import { serverKey } from "../../../stack/server-key.mjs";
18
26
 
19
27
  export { envelopeOf, formatOf, plaintextOf } from "./secret-document.mjs";
20
28
  export { setSecretValues } from "./secret-store.mjs";
21
29
 
22
- const VERBS = new Set(["init", "list", "get", "set", "unset", "rename", "remove", "materialize", "verify"]);
30
+ const VERBS = new Set(["init", "list", "get", "set", "unset", "rename", "remove", "materialize", "verify", "recipients", "migrate", "split", "audit", "server-key"]);
31
+ const MOVE_VERBS = new Set(["migrate", "split"]);
32
+ const LIST_FLAGS = ["--add", "--remove", "--from-file", "--group"];
23
33
  const EDIT_VERBS = new Set(["unset", "rename", "remove"]);
24
34
 
25
35
  function valueOption(opts, flag, value) {
@@ -35,9 +45,13 @@ function parse(argv) {
35
45
  for (let i = 0; i < argv.length; i += 1) {
36
46
  const arg = argv[i];
37
47
  const [flag, inline] = arg.split(/=(.*)/su);
38
- if (["--cwd", "--generate"].includes(flag)) {
48
+ if (LIST_FLAGS.includes(flag)) {
49
+ const value = inline ?? argv[++i];
50
+ if (value === undefined || value.startsWith("--")) throw new SecretError(`${flag} needs a value`);
51
+ (opts[flag.slice(2)] ??= []).push(value);
52
+ } else if (["--cwd", "--generate", "--to", "--group-file", "--backup", "--restore"].includes(flag)) {
39
53
  valueOption(opts, flag, inline ?? argv[++i]);
40
- } else if (["--to-file", "--reveal", "--execute", "--force-nonempty"].includes(flag)) {
54
+ } else if (["--to-file", "--reveal", "--execute", "--force-nonempty", "--into-existing", "--rotate"].includes(flag)) {
41
55
  if (inline !== undefined && !["true", "false"].includes(inline))
42
56
  throw new SecretError(`${flag} expects true or false`);
43
57
  opts[flag.slice(2)] = inline !== "false";
@@ -48,27 +62,31 @@ function parse(argv) {
48
62
  return opts;
49
63
  }
50
64
 
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
65
  function usageArguments(verb) {
60
66
  if (verb === "init") return "";
61
- if (verb === "set") return "<env> <slug> <KEY> [--generate <bytes>]";
67
+ if (verb === "set") return "<env> <slug> <KEY> [KEY ...] [--generate <bytes>]";
62
68
  if (verb === "get") return "<env> <slug> [KEY] --to-file [--reveal]";
63
69
  if (verb === "unset") return "<env> <slug> <KEY> [--execute]";
64
70
  if (verb === "rename") return "<env> <slug> <OLD> <NEW> [--execute]";
65
71
  if (verb === "remove") return "<env> <slug> [--execute] [--force-nonempty]";
72
+ if (verb === "recipients") return "<env> [--add <age1...|owner>] [--remove <age1...|owner>]";
73
+ if (verb === "migrate") return "<env> <slug> [--to <slug>] [--into-existing] [--execute]";
74
+ if (verb === "split") return "<env> <slug> [--group KEY=member ...] [--group-file <yaml>] [--into-existing] [--execute]";
75
+ if (verb === "audit") return "[<env>]";
76
+ if (verb === "server-key") return "<env> [--rotate | --backup github | --restore github] [--execute]";
66
77
  return "<env>";
67
78
  }
68
79
 
69
80
  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");
81
+ if (opts.execute !== undefined && !EDIT_VERBS.has(verb) && !MOVE_VERBS.has(verb) && verb !== "server-key")
82
+ throw new SecretError("--execute is only valid for unset, rename, remove, migrate, split and server-key");
83
+ if ((opts.rotate !== undefined || opts.backup !== undefined || opts.restore !== undefined) && verb !== "server-key")
84
+ throw new SecretError("--rotate, --backup and --restore are only valid for server-key");
85
+ if (opts.to !== undefined && verb !== "migrate") throw new SecretError("--to is only valid for migrate");
86
+ if ((opts.group || opts["group-file"] !== undefined) && verb !== "split") throw new SecretError("--group and --group-file are only valid for split");
87
+ if (opts["into-existing"] !== undefined && !MOVE_VERBS.has(verb)) throw new SecretError("--into-existing is only valid for migrate and split");
88
+ if ((opts.add || opts.remove) && verb !== "recipients") throw new SecretError("--add and --remove are only valid for recipients");
89
+ if (opts["from-file"] && verb !== "set") throw new SecretError("--from-file is only valid for set");
72
90
  if (opts["force-nonempty"] !== undefined && verb !== "remove")
73
91
  throw new SecretError("--force-nonempty is only valid for remove");
74
92
  }
@@ -90,11 +108,16 @@ function optionsFor(verb, opts) {
90
108
  list: [1, 1],
91
109
  materialize: [1, 1],
92
110
  verify: [1, 1],
93
- set: [3, 3],
111
+ set: [3, Infinity],
94
112
  get: [2, 3],
95
113
  unset: [3, 3],
96
114
  rename: [4, 4],
97
115
  remove: [2, 2],
116
+ recipients: [1, 1],
117
+ migrate: [2, 2],
118
+ split: [2, 2],
119
+ audit: [0, 1],
120
+ "server-key": [1, 1],
98
121
  }[verb];
99
122
  if (count < bounds[0] || count > bounds[1])
100
123
  throw new SecretError(
@@ -105,70 +128,136 @@ function optionsFor(verb, opts) {
105
128
  outputOptionsFor(verb, opts);
106
129
  editOptionsFor(verb, opts);
107
130
  if (EDIT_VERBS.has(verb)) validateEditNames(verb, opts.positional.slice(2));
108
- if (verb === "set") validateKey(opts.positional[2]);
131
+ if (verb === "set") {
132
+ const keys = opts.positional.slice(2);
133
+ for (const key of keys) validateKey(key);
134
+ const twice = keys.find((key, index) => keys.indexOf(key) !== index);
135
+ if (twice) throw new SecretError(`set names ${twice} twice`);
136
+ }
109
137
  if (verb === "get" && opts.positional[2] !== undefined)
110
138
  validateKey(opts.positional[2]);
111
139
  }
112
140
 
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, "");
141
+ /** The `--from-file KEY=path` values of set: each a named key, read byte for byte by setValues. */
142
+ function fromFiles(entries, keys) {
143
+ const files = {};
144
+ for (const entry of entries ?? []) {
145
+ const at = entry.indexOf("=");
146
+ const key = entry.slice(0, at);
147
+ if (at < 1 || !entry.slice(at + 1)) throw new SecretError("--from-file takes KEY=path");
148
+ if (!keys.includes(key)) throw new SecretError(`--from-file names ${key}, which this set does not`);
149
+ if (Object.hasOwn(files, key)) throw new SecretError(`--from-file names ${key} twice`);
150
+ files[key] = entry.slice(at + 1);
130
151
  }
131
- if (!value) throw new SecretError("the value on stdin is empty");
132
- return value;
152
+ return files;
153
+ }
154
+
155
+ /** What `set` declared in the environment's stack `secrets:`, or the keys to declare by hand; nothing when all were declared. */
156
+ function declarationLine(environment, { declared, byHand }) {
157
+ if (declared.length) return `declared ${declared.join(", ")} in environments.${environment}.secrets of application-stacks.yaml\n`;
158
+ if (byHand.length) return `declare ${byHand.join(", ")} in environments.${environment}.secrets of application-stacks.yaml (this environment's shape is wired by hand)\n`;
159
+ return "";
133
160
  }
134
161
 
135
- /** All app secrets verbs; stdin, sops, random and tool ports permit isolated custody specs. */
136
- export function secretMain(
162
+ /** The app root of a sealed member: `<root>/.starcistacks/<env>/secrets/<slug>.enc`. */
163
+ const appRootOf = (file) => path.resolve(path.dirname(file), "..", "..", "..");
164
+
165
+ /**
166
+ * 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
167
+ * open it (the recipient it is sealed to, and where this machine holds that identity, or the fix when it holds none). Names
168
+ * and paths only.
169
+ */
170
+ function sealedLine(environment, slug, keys, file, created, seam) {
171
+ const head = `sealed ${environment}/${slug} (${keys.join(", ")})`;
172
+ if (!created) return `${head}\n`;
173
+ let recipients = [];
174
+ try { recipients = envelopeOf(fs.readFileSync(file, "utf8")).recipients; }
175
+ catch { recipients = []; }
176
+ const of = recipients.length ? ` of ${recipients.join(", ")}` : "";
177
+ let held = "";
178
+ if (seam.identity?.source) held = `; this machine holds it in ${seam.identity.source}`;
179
+ else if (seam.identity)
180
+ held = `; this machine holds none: add SOPS_AGE_KEY to ${path.join(appRootOf(file), ".claude", "secret.env")} before the next set`;
181
+ return `${head}; a new member: adding keys to it later needs the age identity${of}${held}\n`;
182
+ }
183
+
184
+ /** `server-key`: the deploy server's identity through the stack transport (`serverKeyIo`: { io, deps } of the specs), lines out. */
185
+ async function serverKeyVerb(repoRoot, environment, opts, { env, stdout, stderr, open, serverKeyIo }) {
186
+ const ctx = stackContext({ cwd: repoRoot, env, stdout, stderr, openSops: open, ...serverKeyIo.io });
187
+ try {
188
+ const result = await serverKey(ctx, environment, {
189
+ execute: opts.execute === true, rotate: opts.rotate === true, backup: opts.backup, restore: opts.restore,
190
+ }, serverKeyIo.deps ?? {});
191
+ for (const line of result.lines) stdout(`${line}\n`);
192
+ return result.ok ? 0 : 1;
193
+ } catch (error) {
194
+ if (!(error instanceof StackError)) throw error;
195
+ stderr(`starci app secret: ${error.message}\n`);
196
+ return error.exit;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * All app secrets verbs; stdin, prompt, sops, random and tool ports permit isolated custody specs. `hostIdentity`
202
+ * (`{ label, read }`) is the harness CLI's secret.env SOPS_AGE_KEY, the last place before keys.txt the age identity is looked for.
203
+ */
204
+ export async function secretMain(
137
205
  argv,
138
206
  {
139
207
  stdout = (s) => process.stdout.write(s),
140
208
  stderr = (s) => process.stderr.write(s),
141
- stdin = readStdin,
209
+ stdin,
210
+ prompt,
211
+ hostIdentity = null,
142
212
  sops = null,
143
213
  random = randomBytes,
144
214
  env = process.env,
145
215
  cwd = invocationDir(),
146
216
  platform = process.platform,
147
217
  tools,
218
+ serverKeyIo = {},
148
219
  } = {},
149
220
  ) {
150
221
  const [verb, ...rest] = argv;
151
222
  try {
152
223
  if (!VERBS.has(verb))
153
224
  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>",
225
+ "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>] | server-key <env> [--rotate | --backup github | --restore github] [--execute]",
155
226
  );
156
227
  const opts = parse(rest);
157
228
  optionsFor(verb, opts);
158
229
  const repoRoot = path.resolve(cwd, opts.cwd ?? ".");
159
230
  if (verb === "init")
160
231
  return publicStorage(() =>
161
- initializeSecrets(repoRoot, { env, platform, tools, stdout }),
232
+ initializeSecrets(repoRoot, { env, platform, tools, stdout, host: hostIdentity }),
233
+ );
234
+ if (verb === "audit") return printAudit(repoRoot, opts.positional[0] ?? null, stdout);
235
+ if (verb === "recipients")
236
+ return publicStorage(() =>
237
+ environmentRecipients(repoRoot, opts.positional[0], {
238
+ add: opts.add ?? [], remove: opts.remove ?? [], stdout,
239
+ owner: () => ownerRecipient(repoRoot, env, { platform, tools, host: hostIdentity }),
240
+ }),
162
241
  );
163
242
  const [environment, slug, key] = opts.positional;
243
+ if (verb === "server-key")
244
+ return await serverKeyVerb(repoRoot, environment, opts, {
245
+ env, stdout, stderr, serverKeyIo,
246
+ open: () => sops ?? defaultSops(env, repoRoot, platform, tools, hostIdentity),
247
+ });
164
248
  const directory = secretsDirectory(repoRoot, environment);
165
249
  if (verb === "list") return listSecrets(directory, stdout);
166
- const open = () => sops ?? defaultSops(env, repoRoot, platform, tools);
250
+ const open = () => sops ?? defaultSops(env, repoRoot, platform, tools, hostIdentity);
167
251
  if (EDIT_VERBS.has(verb))
168
252
  return editSecret(verb, {
169
253
  directory, environment, slug, names: opts.positional.slice(2),
170
254
  execute: opts.execute === true, force: opts["force-nonempty"] === true, sops: open, stdout,
171
255
  });
256
+ if (MOVE_VERBS.has(verb)) {
257
+ const move = { directory, environment, member: slug, to: opts.to, intoExisting: opts["into-existing"] === true, execute: opts.execute === true, sops: open, stdout };
258
+ if (verb === "migrate") return migrateMember(move);
259
+ return splitMember({ ...move, groups: groupMap(opts.group, opts["group-file"] === undefined ? null : path.resolve(cwd, opts["group-file"])) });
260
+ }
172
261
  const seam = open();
173
262
  if (verb === "verify")
174
263
  return verifySecrets({ repoRoot, directory, environment, sops: seam, stdout });
@@ -177,9 +266,14 @@ export function secretMain(
177
266
  return materializeSecrets(directory, seam, output);
178
267
  const file = fileOf(directory, slug);
179
268
  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`);
269
+ const keys = opts.positional.slice(2);
270
+ const created = !fs.existsSync(file);
271
+ // Adding a key decrypts the member, so its identity is checked before any value is asked for; replacing every key does not.
272
+ if (!created && !envelopeOf(fs.readFileSync(file, "utf8")).keys.every((name) => keys.includes(name))) seam.requireIdentity?.(file);
273
+ const values = await setValues(keys, { generate: opts.generate, stdin, prompt, random, fromFile: fromFiles(opts["from-file"], keys) });
274
+ writeSecret({ file, values, age: [], sops: seam });
275
+ stdout(sealedLine(environment, slug, keys, file, created, seam));
276
+ stdout(declarationLine(environment, declareKeys(repoRoot, environment, slug, keys)));
183
277
  return 0;
184
278
  } catch (error) {
185
279
  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.
@@ -13,6 +13,7 @@ export const KEY = /^[A-Z][A-Z0-9_]*$/u;
13
13
  const VOLUME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
14
14
  const HOST_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
15
15
  const REMOTE_BASE = /^\/[A-Za-z0-9._/-]+$/u;
16
+ const AGE_RECIPIENT = /^age1[a-z0-9]+$/u;
16
17
  const HTTP_URL = /^https?:\/\/[^\s]+$/u;
17
18
  const PINNED_HELPER_IMAGE = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}$)[0-9a-f]{64}$/u;
18
19
  const ARGUMENT = /^[A-Za-z0-9_@%+=:,./-]+$/u;
@@ -20,7 +21,7 @@ const UPPER_ENV = KEY;
20
21
 
21
22
  const FIELDS = {
22
23
  deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'ingress', 'registry'],
23
- remote: ['host', 'releasesBase', 'sealed'],
24
+ remote: ['host', 'releasesBase', 'sealed', 'serverRecipient'],
24
25
  sealed: ['ops', 'sshKey', 'knownHosts'],
25
26
  image: ['app', 'buildArgs'],
26
27
  buildArgs: ['values', 'sealed'],
@@ -51,9 +52,15 @@ function stringList(c, at, value, itemPattern, { nonEmpty = false } = {}) {
51
52
  return value;
52
53
  }
53
54
 
55
+ /**
56
+ * `deploy.remote`: the ssh alias, the releases root, the sealed owner-machine session and `serverRecipient`, the public age
57
+ * recipient of the server's own identity that `starci app secret server-key` records.
58
+ */
54
59
  function remote(c, block) {
55
- if (block === undefined) return { host: 'vps', releasesBase: null, sealed: null };
56
- if (!closedFields(c, 'deploy.remote', block, FIELDS.remote)) return { host: 'vps', releasesBase: null, sealed: null };
60
+ const fallback = { host: 'vps', releasesBase: null, sealed: null, serverRecipient: null };
61
+ if (block === undefined) return fallback;
62
+ if (!closedFields(c, 'deploy.remote', block, FIELDS.remote)) return fallback;
63
+ if (block.serverRecipient !== undefined) patternField(c, 'deploy.remote.serverRecipient', block.serverRecipient, AGE_RECIPIENT);
57
64
  if (block.host !== undefined) nameField(c, 'deploy.remote.host', block.host, HOST_ALIAS);
58
65
  if (block.releasesBase !== undefined && (!REMOTE_BASE.test(String(block.releasesBase)) || String(block.releasesBase).includes('..')))
59
66
  c.problem('deploy.remote.releasesBase', 'must be an absolute path without ..');
@@ -62,7 +69,7 @@ function remote(c, block) {
62
69
  for (const key of FIELDS.sealed) nameField(c, `deploy.remote.sealed.${key}`, block.sealed[key]);
63
70
  sealed = { ...block.sealed };
64
71
  }
65
- return { host: block.host ?? 'vps', releasesBase: block.releasesBase ?? null, sealed };
72
+ return { host: block.host ?? 'vps', releasesBase: block.releasesBase ?? null, sealed, serverRecipient: block.serverRecipient ?? null };
66
73
  }
67
74
 
68
75
  function imageBuildArgs(c, at, block, members) {
@@ -124,16 +131,30 @@ function grants(c, block, services, members) {
124
131
  return out;
125
132
  }
126
133
 
134
+ /**
135
+ * `deploy.secrets.settings`: one `{ member, keys }` or a list of them, one per member whose plain one-line values the stack
136
+ * interpolates; a key named by two entries is refused, so every setting has one source.
137
+ */
127
138
  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 }) };
139
+ if (block === undefined) return [];
140
+ const entries = Array.isArray(block) ? block : [block];
141
+ const seen = new Set();
142
+ return entries.flatMap((entry, index) => {
143
+ const at = Array.isArray(block) ? `deploy.secrets.settings[${index}]` : 'deploy.secrets.settings';
144
+ if (!closedFields(c, at, entry, FIELDS.settings)) return [];
145
+ memberRef(c, `${at}.member`, entry.member, members);
146
+ const keys = stringList(c, `${at}.keys`, entry.keys, KEY, { nonEmpty: true });
147
+ for (const key of keys) {
148
+ if (seen.has(key)) c.problem(`${at}.keys`, `${key} is named by another settings entry`);
149
+ seen.add(key);
150
+ }
151
+ return [{ member: entry.member, keys }];
152
+ });
132
153
  }
133
154
 
134
155
  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 };
156
+ if (block === undefined) return { grants: [], settings: [] };
157
+ if (!closedFields(c, 'deploy.secrets', block, FIELDS.secrets)) return { grants: [], settings: [] };
137
158
  if (block.grants !== undefined && !Array.isArray(block.grants)) c.problem('deploy.secrets.grants', 'must be a list');
138
159
  return { grants: grants(c, block.grants, services, members), settings: secretSettings(c, block.settings, members) };
139
160
  }
@@ -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>]
@@ -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>]
@@ -460,11 +461,12 @@ function errorMessage(error) {
460
461
  /**
461
462
  * `harness` ({ version }) is the CLI that runs this package in-process: its version is the starci-harness an app is judged and
462
463
  * 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.
464
+ * the HFS check and `stackIo` Swarm tools; production defaults execute every check. `hostIdentity` ({ label, read }) is the
465
+ * harness CLI's own secret.env SOPS_AGE_KEY, which `secret` reads last for the age identity; `prompt` replaces its masked prompt.
464
466
  */
465
467
  export async function main(argv, {
466
468
  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,
469
+ env = process.env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp,
468
470
  } = {}) {
469
471
  try {
470
472
  const global = globalOptions(argv, path.resolve(baseCwd));
@@ -472,7 +474,7 @@ export async function main(argv, {
472
474
  const out = global.quiet ? () => {} : stdout;
473
475
  if (global.help) { out(USAGE); return 0; }
474
476
  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 });
477
+ 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
478
  } catch (error) {
477
479
  stderr(errorMessage(error));
478
480
  return 2;
@@ -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/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)));