@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
@@ -0,0 +1,369 @@
1
+ // secret-standard.mjs - the app secrets standard (ruling app-secrets-one-standard, contexts/project/stacks.md
2
+ // PROJECT-STACKS-5) judged and repaired: `audit` names every deviation, `migrate` moves a member to `<member>.enc.yaml` sealed
3
+ // to its environment's recipients (or merges it into an existing member), `split` divides a member into one member per provider
4
+ // or per the owner's --group. A member every key of which the stack declares in it is a deliberate group, not a mix. The dry run
5
+ // reads only envelope key names and recipients; an executed move decrypts in memory through the sops seam and seals again,
6
+ // never writing plaintext to disk or printing a value.
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { parseYaml } from "../../engine/yaml.mjs";
10
+ import { lsFiles } from "../api/git/ls-files.mjs";
11
+ import { MEMBER_SUFFIX, isLegacyName, memberOfName } from "../lib/sealed-member.mjs";
12
+ import { configuredRecipients } from "./secret-init.mjs";
13
+ import { envelopeOf, validateMap } from "./secret-document.mjs";
14
+ import { deleteSealed, fileOf, sealedFiles, writeSecret } from "./secret-store.mjs";
15
+ import { SecretError } from "./secret-errors.mjs";
16
+
17
+ /** The provider a key belongs to: its first word (`SEPAY_IPN_SECRET_KEY` is `sepay`, `OPENROUTER_API_KEY` is `openrouter`). */
18
+ export const providerOf = (key) => key.split("_")[0].toLowerCase();
19
+
20
+ const sameSet = (left, right) => left.length === right.length && left.every((item) => right.includes(item));
21
+ const keyList = (keys) => keys.join(", ");
22
+
23
+ /** The keys an environment's `secrets:` declares for `member`: an entry naming `key` with that `member`, or a whole-member entry. */
24
+ function declaredKeys(spec, member, keys) {
25
+ const entries = Array.isArray(spec?.secrets) ? spec.secrets : [];
26
+ const memberOfEntry = (entry) => entry?.member ?? /^secrets\/([a-z0-9-]+)/u.exec(entry?.where ?? "")?.[1] ?? entry?.name;
27
+ if (entries.some((entry) => entry?.key === undefined && memberOfEntry(entry) === member)) return keys;
28
+ return entries.filter((entry) => entry?.key !== undefined && memberOfEntry(entry) === member).map((entry) => entry.key);
29
+ }
30
+
31
+ /**
32
+ * True when the stack groups `member` on purpose: the environment's `secrets:` declares every key of it per key, naming this member.
33
+ * A member the owner groups (an app's own keys, the keys one service reads) holds keys of several first words by design.
34
+ */
35
+ function declaredGroup(spec, member, keys) {
36
+ const entries = Array.isArray(spec?.secrets) ? spec.secrets : [];
37
+ return keys.length > 0 && keys.every((key) => entries.some((entry) => entry?.key === key && entry?.member === member));
38
+ }
39
+
40
+ /** The tracked member file names of one environment, or null outside a git work tree. */
41
+ function trackedNames(repoRoot, environment) {
42
+ const run = lsFiles(["-z", "--", `.starcistacks/${environment}/secrets`], { cwd: repoRoot, encoding: "utf8" });
43
+ if (run.error || run.status !== 0) return null;
44
+ return new Set(String(run.stdout).split("\0").filter(Boolean).map((file) => path.posix.basename(file)));
45
+ }
46
+
47
+ const recipientsFor = (repoRoot, file) => {
48
+ try { return configuredRecipients(repoRoot, file); } catch { return []; }
49
+ };
50
+
51
+ /**
52
+ * Every deviation of the app at `repoRoot` (one environment, else each with a secrets folder): LEGACY_MEMBER, MIXED_PROVIDERS,
53
+ * RECIPIENT_DRIFT, UNDECLARED_KEY (only where application-stacks.yaml declares the environment), UNTRACKED. Names only.
54
+ */
55
+ export function auditSecrets(repoRoot, environment = null) {
56
+ const stacksFile = path.join(repoRoot, ".starcistacks", "application-stacks.yaml");
57
+ const stacks = fs.existsSync(stacksFile) ? parseYaml(fs.readFileSync(stacksFile, "utf8")) ?? {} : {};
58
+ const root = path.join(repoRoot, ".starcistacks");
59
+ const environments = environment
60
+ ? [environment]
61
+ : (fs.existsSync(root) ? fs.readdirSync(root, { withFileTypes: true }) : [])
62
+ .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(root, entry.name, "secrets")))
63
+ .map((entry) => entry.name)
64
+ .sort();
65
+ const findings = [];
66
+ for (const env of environments) {
67
+ const directory = path.join(root, env, "secrets");
68
+ const spec = stacks.environments?.[env];
69
+ const tracked = trackedNames(repoRoot, env);
70
+ for (const name of sealedFiles(directory)) {
71
+ const member = memberOfName(name);
72
+ const file = path.join(directory, name);
73
+ const envelope = envelopeOf(fs.readFileSync(file, "utf8"));
74
+ const add = (code, detail) => findings.push({ code, env, member: name, detail });
75
+ const providers = [...new Set(envelope.keys.map(providerOf))];
76
+ if (isLegacyName(name)) add("LEGACY_MEMBER", `is a dotenv .enc member; move it: starci app secret migrate ${env} ${member} [--to <provider>] --execute`);
77
+ if (providers.length > 1 && !declaredGroup(spec, member, envelope.keys))
78
+ add("MIXED_PROVIDERS", `holds keys of ${providers.length} providers by their first word (${providers.join(", ")}) that the stack does not group in it; split it (starci app secret split ${env} ${member} [--group KEY=member] --execute) or declare each key with member: ${member} in environments.${env}.secrets`);
79
+ const wanted = recipientsFor(repoRoot, path.join(directory, `${member}${MEMBER_SUFFIX}`));
80
+ if (!isLegacyName(name) && wanted.length && !sameSet(wanted, envelope.recipients))
81
+ add("RECIPIENT_DRIFT", `is sealed to ${keyList(envelope.recipients)}, its environment's rule names ${keyList(wanted)}; seal it again: starci app secret migrate ${env} ${member} --execute`);
82
+ if (spec) {
83
+ const declared = declaredKeys(spec, member, envelope.keys);
84
+ const missing = envelope.keys.filter((key) => !declared.includes(key));
85
+ if (missing.length) add("UNDECLARED_KEY", `holds ${keyList(missing)}, not declared in environments.${env}.secrets of application-stacks.yaml`);
86
+ }
87
+ if (tracked && !tracked.has(name)) add("UNTRACKED", "is not committed; commit it (it is ciphertext)");
88
+ }
89
+ const drift = serverRecipientDrift(repoRoot, env, spec);
90
+ if (drift) findings.push({ code: "RECIPIENT_DRIFT", env, member: ".sops.yaml", detail: drift });
91
+ for (const entry of Array.isArray(spec?.secrets) ? spec.secrets : []) {
92
+ const member = entry?.member ?? /^secrets\/([a-z0-9-]+)/u.exec(entry?.where ?? "")?.[1];
93
+ if (member && !fs.existsSync(fileOf(directory, member)))
94
+ findings.push({ code: "MISSING", env, member, detail: `is declared by environments.${env}.secrets ${entry.name} but no ${member}${MEMBER_SUFFIX} is sealed; remove the entry or seal the member` });
95
+ }
96
+ }
97
+ return findings;
98
+ }
99
+
100
+ const indentOf = (line) => /^ */u.exec(line)[0].length;
101
+ const meaningful = (line) => line.trim() !== "" && !line.trimStart().startsWith("#");
102
+
103
+ /** The [from, to) lines of the block under line `at`: every following line indented deeper, or blank, or a comment. */
104
+ function blockOf(lines, at) {
105
+ const indent = indentOf(lines[at]);
106
+ let to = at + 1;
107
+ let last = at;
108
+ for (; to < lines.length && (!meaningful(lines[to]) || indentOf(lines[to]) > indent); to += 1) if (meaningful(lines[to])) last = to;
109
+ return { from: at + 1, to: last + 1 };
110
+ }
111
+
112
+ /** The line of `key:` among the direct children of a block, or -1. */
113
+ function childLine(lines, block, key) {
114
+ const first = lines.slice(block.from, block.to).find(meaningful);
115
+ if (first === undefined) return -1;
116
+ const indent = indentOf(first);
117
+ for (let at = block.from; at < block.to; at += 1)
118
+ if (meaningful(lines[at]) && indentOf(lines[at]) === indent && lines[at].trimStart().startsWith(`${key}:`)) return at;
119
+ return -1;
120
+ }
121
+
122
+ /**
123
+ * Declares each of `keys` of `member` in environment `environment`'s `secrets:` of application-stacks.yaml, one entry per key
124
+ * (`name: <kebab of the key>, member, key, where: secrets/<member>`), so the key is materialized as its own `<KEY>_FILE`. A key
125
+ * already declared is left alone. Edits the text in place (comments and order kept) and proves the result parses to the
126
+ * expected document; a shape this edit cannot place returns the keys to declare by hand.
127
+ */
128
+ export function declareKeys(repoRoot, environment, member, keys) {
129
+ const file = path.join(repoRoot, ".starcistacks", "application-stacks.yaml");
130
+ if (!fs.existsSync(file)) return { declared: [], byHand: [] };
131
+ const text = fs.readFileSync(file, "utf8");
132
+ const document = parseYaml(text) ?? {};
133
+ const spec = document.environments?.[environment];
134
+ if (!spec) return { declared: [], byHand: [] };
135
+ const missing = keys.filter((key) => !declaredKeys(spec, member, keys).includes(key));
136
+ if (!missing.length) return { declared: [], byHand: [] };
137
+ // A swarm environment's secret is a versioned external secret its stack file names (runtimeName, deploy grants): its
138
+ // declaration is wired with the stack, not appended here.
139
+ if (spec.runtime === "docker-swarm") return { declared: [], byHand: missing };
140
+ const sibling = Object.values(document.environments).flatMap((each) => (Array.isArray(each?.secrets) ? each.secrets : []))[0];
141
+ const entries = missing.map((key) => ({
142
+ name: key.toLowerCase().replaceAll("_", "-"), member, key, where: `secrets/${member}`,
143
+ recipientPolicy: "its environment's rule of the app's managed .sops.yaml",
144
+ keyCustody: sibling?.keyCustody ?? "SOPS_AGE_KEY in the harness secret.env",
145
+ }));
146
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
147
+ const lines = text.replace(/\r\n/gu, "\n").split("\n");
148
+ const root = lines.findIndex((line) => /^environments:\s*(?:#.*)?$/u.test(line));
149
+ const envLine = root < 0 ? -1 : childLine(lines, blockOf(lines, root), environment);
150
+ const secrets = envLine < 0 ? -1 : childLine(lines, blockOf(lines, envLine), "secrets");
151
+ if (secrets < 0 || !/^ *secrets:\s*(?:\[\s*\])?\s*(?:#.*)?$/u.test(lines[secrets])) return { declared: [], byHand: missing };
152
+ const pad = indentOf(lines[secrets]);
153
+ lines[secrets] = `${" ".repeat(pad)}secrets:`;
154
+ const items = blockOf(lines, secrets);
155
+ const firstItem = lines.slice(items.from, items.to).find(meaningful);
156
+ const dash = " ".repeat(firstItem === undefined ? pad + 2 : indentOf(firstItem));
157
+ const rendered = entries.flatMap((entry) => Object.entries(entry).map(([name, value], index) => `${dash}${index ? " " : "- "}${name}: ${JSON.stringify(value)}`));
158
+ lines.splice(items.to, 0, ...rendered);
159
+ const expected = structuredClone(document);
160
+ const target = expected.environments[environment];
161
+ target.secrets = [...(Array.isArray(target.secrets) ? target.secrets : []), ...entries];
162
+ const edited = lines.join(newline);
163
+ let parsed;
164
+ try { parsed = parseYaml(edited); } catch { parsed = undefined; }
165
+ if (JSON.stringify(parsed) !== JSON.stringify(expected)) return { declared: [], byHand: missing };
166
+ fs.writeFileSync(file, edited);
167
+ return { declared: missing, byHand: [] };
168
+ }
169
+
170
+ /**
171
+ * Records `recipient` as `environments.<environment>.deploy.remote.serverRecipient` of application-stacks.yaml: the public age
172
+ * recipient of the deploy server's own identity, which `starci app secret server-key` made on the server and audit holds the
173
+ * environment's rule to. Edits the text in place (comments and order kept), replacing the line or adding it as the last child of
174
+ * `remote:`, and proves the result parses to the expected document; a shape this edit cannot place is refused.
175
+ */
176
+ export function recordServerRecipient(repoRoot, environment, recipient) {
177
+ const file = path.join(repoRoot, ".starcistacks", "application-stacks.yaml");
178
+ const text = fs.readFileSync(file, "utf8");
179
+ const document = parseYaml(text) ?? {};
180
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
181
+ const lines = text.replace(/\r\n/gu, "\n").split("\n");
182
+ const root = lines.findIndex((line) => /^environments:\s*(?:#.*)?$/u.test(line));
183
+ const envLine = root < 0 ? -1 : childLine(lines, blockOf(lines, root), environment);
184
+ const deploy = envLine < 0 ? -1 : childLine(lines, blockOf(lines, envLine), "deploy");
185
+ const remote = deploy < 0 ? -1 : childLine(lines, blockOf(lines, deploy), "remote");
186
+ const unplaced = new SecretError(`cannot record the server recipient in environments.${environment}.deploy.remote of application-stacks.yaml; add serverRecipient: ${recipient} there by hand`);
187
+ if (remote < 0 || !/^ *remote:\s*(?:#.*)?$/u.test(lines[remote])) throw unplaced;
188
+ const block = blockOf(lines, remote);
189
+ const own = childLine(lines, block, "serverRecipient");
190
+ const first = lines.slice(block.from, block.to).find(meaningful);
191
+ const pad = " ".repeat(first === undefined ? indentOf(lines[remote]) + 2 : indentOf(first));
192
+ if (own >= 0) lines[own] = `${pad}serverRecipient: ${recipient}`;
193
+ else lines.splice(block.to, 0, `${pad}serverRecipient: ${recipient}`);
194
+ const expected = structuredClone(document);
195
+ expected.environments[environment].deploy.remote.serverRecipient = recipient;
196
+ const edited = lines.join(newline);
197
+ let parsed;
198
+ try { parsed = parseYaml(edited); } catch { parsed = undefined; }
199
+ if (JSON.stringify(parsed) !== JSON.stringify(expected)) throw unplaced;
200
+ if (edited !== text) fs.writeFileSync(file, edited);
201
+ }
202
+
203
+ /**
204
+ * The RECIPIENT_DRIFT of an environment's rule against its deploy server: a rule that does not name the recorded
205
+ * `deploy.remote.serverRecipient` together with another recipient (the owner key), or a production swarm deploy with a remote
206
+ * and no server recipient recorded. Null when the rule holds.
207
+ */
208
+ function serverRecipientDrift(repoRoot, env, spec) {
209
+ const remote = spec?.deploy?.remote;
210
+ if (!remote || typeof remote !== "object") return null;
211
+ const server = remote.serverRecipient;
212
+ if (server === undefined)
213
+ return env === "production" && spec.runtime === "docker-swarm"
214
+ ? `records no deploy server recipient; production is sealed to the server key and the owner key: starci app secret server-key ${env} --execute`
215
+ : null;
216
+ const rule = recipientsFor(repoRoot, path.join(repoRoot, ".starcistacks", env, "secrets", `member${MEMBER_SUFFIX}`));
217
+ if (!rule.includes(server)) return `its rule names ${keyList(rule) || "no recipient"}, not the deploy server recipient ${server}; run starci app secret server-key ${env} --execute`;
218
+ if (rule.length < 2) return `its rule names only the deploy server recipient ${server}; add the owner key: starci app secret recipients ${env} --add owner, then starci app secret server-key ${env} --execute`;
219
+ return null;
220
+ }
221
+
222
+ /** `starci app secret audit [<env>]`: each deviation on its own line and exit 1, else one clean line and exit 0. */
223
+ export function printAudit(repoRoot, environment, stdout) {
224
+ const findings = auditSecrets(repoRoot, environment);
225
+ for (const finding of findings) stdout(`${finding.code} ${finding.env}/${finding.member}: ${finding.detail}\n`);
226
+ if (findings.length) return 1;
227
+ stdout(`app secrets follow the standard${environment ? ` in ${environment}` : ""}\n`);
228
+ return 0;
229
+ }
230
+
231
+ /** Seals `map` as member `<slug>.enc.yaml` to the recipients its environment's rule names; `map` holds every key it keeps. */
232
+ function sealFresh(directory, slug, map, sops) {
233
+ const file = path.join(directory, `${slug}${MEMBER_SUFFIX}`);
234
+ writeSecret({ file, values: map, age: [], sops });
235
+ return file;
236
+ }
237
+
238
+ function plannedMember(directory, member) {
239
+ const file = fileOf(directory, member);
240
+ if (!fs.existsSync(file)) throw new SecretError(`${member} is not a sealed member of this environment`);
241
+ return { file, envelope: envelopeOf(fs.readFileSync(file, "utf8")) };
242
+ }
243
+
244
+ /**
245
+ * Each target of a move: its keys, whether it is the source member itself (`own`, re-sealed in place) and whether it is another
246
+ * member that already exists (`existing`, merged into only with --into-existing, never a legacy one). Refuses before any read.
247
+ */
248
+ function plannedTargets({ directory, environment, file, groups, intoExisting, verb }) {
249
+ return [...groups].map(([target, keys]) => {
250
+ const resolved = fileOf(directory, target);
251
+ const own = resolved === file && !isLegacyName(path.basename(file));
252
+ const existing = resolved !== file && fs.existsSync(resolved);
253
+ const label = `${environment}/${path.basename(resolved)}`;
254
+ if (existing && isLegacyName(path.basename(resolved))) throw new SecretError(`${label} is a legacy member; migrate it first, then merge into it`);
255
+ if (existing && !intoExisting) throw new SecretError(`${label} already exists; ${verb} merges into it only with --into-existing`);
256
+ const held = existing ? envelopeOf(fs.readFileSync(resolved, "utf8")).keys : [];
257
+ const overlap = keys.filter((key) => held.includes(key));
258
+ return { target, keys, own, existing, overlap, file: existing ? resolved : path.join(directory, `${target}${MEMBER_SUFFIX}`) };
259
+ });
260
+ }
261
+
262
+ const planText = (environment, targets) => targets.map(({ target, keys, existing, overlap }) => {
263
+ const merge = overlap.length ? `; it already holds ${keyList(overlap)}, which must be identical` : "";
264
+ return `${environment}/${target}${MEMBER_SUFFIX} (${keyList(keys)})${existing ? ` merged into the existing member${merge}` : ""}`;
265
+ }).join(", ");
266
+
267
+ /**
268
+ * Moves the keys of the source member `file` into `targets`, decrypting in memory only. Every existing target is opened and
269
+ * checked first: a key it already holds with another value refuses the whole move, naming the keys and never a value. Then
270
+ * each target is written once, and only after every target is written is the source removed (or, for an own target, the
271
+ * re-sealed copy staged beside it is moved over it), so no key is ever held by no member.
272
+ */
273
+ function moveKeys({ directory, environment, file, envelope, targets, member, sops }) {
274
+ const seam = sops();
275
+ const map = seam.decrypt(file, envelope.format);
276
+ validateMap(map);
277
+ if (!sameSet(Object.keys(map), envelope.keys)) throw new SecretError("sops returned keys that differ from the envelope");
278
+ const writes = targets.map((target) => {
279
+ const values = Object.fromEntries(target.keys.map((key) => [key, map[key]]));
280
+ if (!target.existing) return { target, values, colliding: [] };
281
+ const held = seam.decrypt(target.file, envelopeOf(fs.readFileSync(target.file, "utf8")).format);
282
+ validateMap(held);
283
+ const colliding = target.keys.filter((key) => Object.hasOwn(held, key) && held[key] !== values[key]);
284
+ return { target, values: { ...held, ...values }, colliding };
285
+ });
286
+ const collisions = writes.filter((write) => write.colliding.length);
287
+ if (collisions.length)
288
+ throw new SecretError(`refused, nothing was written: ${collisions.map(({ target, colliding }) => `${environment}/${path.basename(target.file)} already holds ${keyList(colliding)} with another value`).join("; ")}; rename or unset one side first`);
289
+ const staging = `${member}-staged`;
290
+ if (targets.some((target) => target.own) && fs.existsSync(fileOf(directory, staging)))
291
+ throw new SecretError(`${environment}/${staging}${MEMBER_SUFFIX} exists; remove it before moving ${member}`);
292
+ let staged = null;
293
+ for (const { target, values } of writes) {
294
+ if (target.own) staged = sealFresh(directory, staging, values, seam);
295
+ else writeSecret({ file: target.file, values, age: [], sops: seam });
296
+ }
297
+ if (staged) fs.renameSync(staged, file);
298
+ else deleteSealed(file);
299
+ }
300
+
301
+ /** The `--group-file` YAML map (`KEY: member`) and the `--group KEY=member` entries as one map; a key named twice is refused. */
302
+ export function groupMap(entries = [], groupFile = null) {
303
+ const pairs = [];
304
+ if (groupFile) {
305
+ if (!fs.existsSync(groupFile)) throw new SecretError(`--group-file ${groupFile} does not exist`);
306
+ let document;
307
+ try { document = parseYaml(fs.readFileSync(groupFile, "utf8")); } catch { document = undefined; }
308
+ if (!document || typeof document !== "object" || Array.isArray(document) || Object.values(document).some((value) => typeof value !== "string"))
309
+ throw new SecretError(`--group-file ${groupFile} must be a YAML map of KEY: member`);
310
+ pairs.push(...Object.entries(document));
311
+ }
312
+ for (const entry of entries) {
313
+ const at = entry.indexOf("=");
314
+ if (at < 1 || !entry.slice(at + 1)) throw new SecretError("--group takes KEY=member");
315
+ pairs.push([entry.slice(0, at), entry.slice(at + 1)]);
316
+ }
317
+ const groups = new Map();
318
+ for (const [key, member] of pairs) {
319
+ if (groups.has(key)) throw new SecretError(`--group names ${key} twice`);
320
+ groups.set(key, member);
321
+ }
322
+ return groups;
323
+ }
324
+
325
+ /**
326
+ * `migrate <env> <member> [--to <slug>] [--into-existing] [--execute]`: moves a member (a legacy dotenv `.enc`, one sealed to
327
+ * other recipients than its rule, or one whose keys belong to another member) to `<slug>.enc.yaml` sealed to the environment's
328
+ * recipients, merging into an existing `<slug>` only with --into-existing, then removes the old file. A dry run until --execute.
329
+ */
330
+ export function migrateMember({ directory, environment, member, to, intoExisting, execute, sops, stdout }) {
331
+ const { file, envelope } = plannedMember(directory, member);
332
+ const targets = plannedTargets({ directory, environment, file, groups: new Map([[to ?? member, envelope.keys]]), intoExisting, verb: "migrate" });
333
+ const plan = `${environment}/${path.basename(file)} to ${planText(environment, targets)}`;
334
+ if (!execute) {
335
+ stdout(`dry run: would move ${plan}; add --execute\n`);
336
+ return 0;
337
+ }
338
+ moveKeys({ directory, environment, file, envelope, targets, member, sops });
339
+ stdout(`moved ${plan}\n`);
340
+ return 0;
341
+ }
342
+
343
+ /**
344
+ * `split <env> <member> [--group KEY=member ...] [--group-file <yaml>] [--into-existing] [--execute]`: divides a member into one
345
+ * `<member>.enc.yaml` per group, each sealed to the environment's recipients, then removes the old file. A key's group is its
346
+ * --group member, else its provider (providerOf); an existing member is merged into only with --into-existing. A dry run until
347
+ * --execute.
348
+ */
349
+ export function splitMember({ directory, environment, member, groups: chosen = new Map(), intoExisting, execute, sops, stdout }) {
350
+ const { file, envelope } = plannedMember(directory, member);
351
+ const stray = [...chosen.keys()].filter((key) => !envelope.keys.includes(key));
352
+ if (stray.length) throw new SecretError(`--group names ${keyList(stray)}, which ${environment}/${member} does not hold`);
353
+ const groups = new Map();
354
+ for (const key of envelope.keys) {
355
+ const group = chosen.get(key) ?? providerOf(key);
356
+ groups.set(group, [...(groups.get(group) ?? []), key]);
357
+ }
358
+ if (groups.size < 2 && groups.has(member))
359
+ throw new SecretError(`${environment}/${member} holds one group's keys; nothing to split (migrate renames a member)`);
360
+ const targets = plannedTargets({ directory, environment, file, groups, intoExisting, verb: "split" });
361
+ const plan = planText(environment, targets);
362
+ if (!execute) {
363
+ stdout(`dry run: would split ${environment}/${path.basename(file)} into ${plan}; add --execute\n`);
364
+ return 0;
365
+ }
366
+ moveKeys({ directory, environment, file, envelope, targets, member, sops });
367
+ stdout(`split ${environment}/${path.basename(file)} into ${plan}\n`);
368
+ return 0;
369
+ }
@@ -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
  }