@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.
- package/CHANGELOG.md +85 -0
- package/README.md +43 -1
- package/db/drift.mjs +8 -6
- package/db/image.mjs +9 -4
- package/db/migrate.mjs +3 -3
- package/package.json +2 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
- package/runtime/knowledge/hfs/rules.yaml +30 -13
- package/runtime/knowledge/hfs/slots.yaml +63 -37
- package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
- package/runtime/knowledge/patterns/be/queues.yaml +2 -3
- package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +17 -17
- package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
- package/runtime/scripts/api/sops/lib.mjs +30 -0
- package/runtime/scripts/api/sops/update-keys.mjs +20 -0
- package/runtime/scripts/gates/starcistacks.mjs +6 -2
- package/runtime/scripts/guards/push-scan.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
- package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
- package/runtime/scripts/hfs/db-errors.mjs +2 -0
- package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
- package/runtime/scripts/hfs/db-squash.mjs +514 -0
- package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
- package/runtime/scripts/hfs/example-secrets.mjs +4 -3
- package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
- package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
- package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
- package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
- package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
- package/runtime/scripts/hfs/rules/edition.mjs +1 -1
- package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
- package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
- package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
- package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
- package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
- package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
- package/runtime/scripts/hfs/sealed-members.mjs +10 -6
- package/runtime/scripts/hfs/secret-files.mjs +16 -0
- package/runtime/scripts/hfs/secret-init.mjs +120 -21
- package/runtime/scripts/hfs/secret-output.mjs +2 -1
- package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
- package/runtime/scripts/hfs/secret-sops.mjs +108 -9
- package/runtime/scripts/hfs/secret-standard.mjs +369 -0
- package/runtime/scripts/hfs/secret-store.mjs +23 -9
- package/runtime/scripts/hfs/secret.mjs +140 -46
- package/runtime/scripts/hfs/slots.mjs +8 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +31 -10
- package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
- package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
- package/runtime/scripts/lib/sealed-member.mjs +36 -0
- package/runtime/scripts/lib/secret-patterns.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.mjs +2 -2
- package/scaffold/add-inbox-lite.mjs +2 -2
- package/scaffold/app.mjs +2 -2
- package/src/main.mjs +6 -4
- package/stack/age-install.mjs +4 -3
- package/stack/build.mjs +1 -1
- package/stack/config.mjs +2 -1
- package/stack/context.mjs +32 -2
- package/stack/docker-transport.mjs +2 -1
- package/stack/remote.mjs +8 -5
- package/stack/sealed.mjs +9 -8
- package/stack/server-key.mjs +296 -0
- package/stack/ssh-session.mjs +7 -1
- package/stack/target-binding.mjs +2 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +36 -10
- package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
- package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
- package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
- package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
- package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
- package/templates/app/gitignore +2 -1
- package/templates/app/gitignore-lite +3 -1
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton-lite/README.md +2 -2
- package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
- package/templates/be/inbox/events.json.tpl +1 -1
- package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
- package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
- package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
- package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
- package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
- package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
- package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
- package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
- package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
- package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
- package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
- package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
- package/templates/be/inbox/platform/migration.sql.tpl +97 -34
- package/templates/be/inbox/service.spec.ts.tpl +1 -1
- package/templates/be/inbox/webhook-door.ts.tpl +2 -2
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
- package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
- package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
- package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
- package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +38 -3
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -4
- package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
- package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
- package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
- package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
- package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
- package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
- package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
- package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
- package/templates/app/db-image/db/Dockerfile +0 -8
- 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
|
|
54
|
+
try { return memberFile(directory, slug); }
|
|
55
|
+
catch (error) { throw new SecretError(error.message); }
|
|
54
56
|
};
|
|
55
57
|
|
|
56
|
-
/**
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
161
|
+
.filter(isSealedName)
|
|
148
162
|
.sort(byCodeUnit)
|
|
149
163
|
: [];
|
|
150
164
|
}
|