@starci/hfs 4.11.0 → 4.11.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +40 -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 +29 -12
- package/runtime/knowledge/hfs/slots.yaml +48 -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/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/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 +93 -9
- package/runtime/scripts/hfs/secret-standard.mjs +315 -0
- package/runtime/scripts/hfs/secret-store.mjs +23 -9
- package/runtime/scripts/hfs/secret.mjs +110 -46
- package/runtime/scripts/hfs/slots.mjs +8 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +20 -6
- 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 +5 -4
- package/stack/build.mjs +1 -1
- package/stack/config.mjs +2 -1
- package/stack/docker-transport.mjs +2 -1
- package/stack/remote.mjs +4 -3
- package/stack/sealed.mjs +9 -8
- package/stack/target-binding.mjs +2 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +33 -10
- 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 +37 -2
- package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -3
- 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/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/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,128 @@
|
|
|
1
|
+
// secret-prompt.mjs - the values `app secret set` seals: typed at a masked terminal prompt, read as KEY=value lines from
|
|
2
|
+
// redirected stdin, or generated. A value stays in memory; the prompt echoes one `*` per character and nothing else.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { SecretError } from "./secret-errors.mjs";
|
|
5
|
+
|
|
6
|
+
const ENTER = new Set(["\r", "\n"]);
|
|
7
|
+
const ERASE = new Set(["\u007f", "\b"]);
|
|
8
|
+
const INTERRUPT = "\u0003";
|
|
9
|
+
const END = "\u0004";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read one line from a terminal with no echo: `input` a TTY read stream (raw mode while reading), `output` where the label and
|
|
13
|
+
* one `*` per character go (stderr, so stdout stays clean). Backspace erases, Enter ends, Ctrl+C or Ctrl+D on an empty line
|
|
14
|
+
* cancels. Resolves the typed text; the stream is paused, unreferenced (a waiting terminal never holds the process open) and
|
|
15
|
+
* its raw mode restored either way.
|
|
16
|
+
*/
|
|
17
|
+
export function promptHidden(label, { input = process.stdin, output = (s) => process.stderr.write(s) } = {}) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const wasRaw = input.isRaw === true;
|
|
20
|
+
let typed = "";
|
|
21
|
+
const finish = (error) => {
|
|
22
|
+
input.removeListener("data", onData);
|
|
23
|
+
input.removeListener("end", onEnd);
|
|
24
|
+
input.setRawMode?.(wasRaw);
|
|
25
|
+
input.pause();
|
|
26
|
+
input.unref?.();
|
|
27
|
+
output("\n");
|
|
28
|
+
if (error) reject(error);
|
|
29
|
+
else resolve(typed);
|
|
30
|
+
};
|
|
31
|
+
const cancelled = () => new SecretError("cancelled at the prompt; nothing was sealed");
|
|
32
|
+
const onEnd = () => finish(typed ? null : cancelled());
|
|
33
|
+
const onData = (chunk) => {
|
|
34
|
+
for (const char of String(chunk)) {
|
|
35
|
+
if (ENTER.has(char)) return finish(null);
|
|
36
|
+
if (char === INTERRUPT || (char === END && !typed)) return finish(cancelled());
|
|
37
|
+
if (ERASE.has(char)) {
|
|
38
|
+
if (typed) {
|
|
39
|
+
typed = [...typed].slice(0, -1).join("");
|
|
40
|
+
output("\b \b");
|
|
41
|
+
}
|
|
42
|
+
} else if (char >= " ") {
|
|
43
|
+
typed += char;
|
|
44
|
+
output("*");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
49
|
+
output(label);
|
|
50
|
+
input.setRawMode?.(true);
|
|
51
|
+
input.setEncoding?.("utf8");
|
|
52
|
+
input.on("data", onData);
|
|
53
|
+
input.on("end", onEnd);
|
|
54
|
+
input.ref?.();
|
|
55
|
+
input.resume();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Each key typed once at the terminal, in order; an empty value seals nothing. */
|
|
60
|
+
async function typedValues(keys, prompt) {
|
|
61
|
+
const values = {};
|
|
62
|
+
for (const key of keys) {
|
|
63
|
+
const value = await prompt(`${key}: `);
|
|
64
|
+
if (!value) throw new SecretError(`${key} is empty; nothing was sealed`);
|
|
65
|
+
values[key] = value;
|
|
66
|
+
}
|
|
67
|
+
return values;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The values of `keys` in redirected stdin text: one key takes the whole text as its value (a trailing newline dropped, so a
|
|
72
|
+
* multi-line value survives); several take one `KEY=value` line each (blank and `#` lines skipped, the value after the first
|
|
73
|
+
* `=`, a trailing CR dropped), every key exactly once and no other.
|
|
74
|
+
*/
|
|
75
|
+
export function stdinValues(keys, text) {
|
|
76
|
+
if (keys.length === 1) {
|
|
77
|
+
const value = String(text).replace(/\r?\n$/u, "");
|
|
78
|
+
if (!value) throw new SecretError("the value on stdin is empty");
|
|
79
|
+
return { [keys[0]]: value };
|
|
80
|
+
}
|
|
81
|
+
const wanted = new Set(keys);
|
|
82
|
+
const values = Object.create(null);
|
|
83
|
+
for (const raw of String(text).split("\n")) {
|
|
84
|
+
const line = raw.replace(/\r$/u, "");
|
|
85
|
+
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
|
86
|
+
const at = line.indexOf("=");
|
|
87
|
+
const key = at < 0 ? line.trim() : line.slice(0, at).trim();
|
|
88
|
+
if (at < 0) throw new SecretError(`stdin line for ${key} has no =; give one KEY=value line per key`);
|
|
89
|
+
if (!wanted.has(key)) throw new SecretError(`stdin names ${key}, which this set does not; nothing was sealed`);
|
|
90
|
+
if (Object.hasOwn(values, key)) throw new SecretError(`stdin gives ${key} twice; nothing was sealed`);
|
|
91
|
+
const value = line.slice(at + 1);
|
|
92
|
+
if (!value) throw new SecretError(`${key} is empty on stdin; nothing was sealed`);
|
|
93
|
+
values[key] = value;
|
|
94
|
+
}
|
|
95
|
+
const missing = keys.filter((key) => !Object.hasOwn(values, key));
|
|
96
|
+
if (missing.length) throw new SecretError(`stdin lacks ${missing.join(", ")}; give one KEY=value line per key; nothing was sealed`);
|
|
97
|
+
return { ...values };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The text of a `--from-file` value, byte for byte (a trailing newline kept); an empty file seals nothing. */
|
|
101
|
+
function fileValue(key, file) {
|
|
102
|
+
let text;
|
|
103
|
+
try { text = fs.readFileSync(file, "utf8"); }
|
|
104
|
+
catch (error) { throw new SecretError(`--from-file ${key}: the file cannot be read (${error.code ?? "unreadable"})`); }
|
|
105
|
+
if (!text) throw new SecretError(`--from-file ${key}: the file is empty; nothing was sealed`);
|
|
106
|
+
return text;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The values `set` seals for `keys`: each `fromFile` key read byte for byte from its file; the rest from `--generate <bytes>`
|
|
111
|
+
* (random base64url per key), else the `stdin` port (a function returning the text, as the harness CLI hands a host key, or a
|
|
112
|
+
* stream with `fd` and `isTTY`): a terminal is prompted through `prompt` (promptHidden by default), anything else is read whole.
|
|
113
|
+
*/
|
|
114
|
+
export async function setValues(keys, { generate, stdin, prompt = promptHidden, random, fromFile = {} }) {
|
|
115
|
+
const files = Object.fromEntries(Object.entries(fromFile).map(([key, file]) => [key, fileValue(key, file)]));
|
|
116
|
+
const rest = keys.filter((key) => !Object.hasOwn(files, key));
|
|
117
|
+
if (!rest.length) return files;
|
|
118
|
+
if (generate !== undefined) {
|
|
119
|
+
const bytes = Number(generate);
|
|
120
|
+
if (!Number.isInteger(bytes) || bytes < 1 || bytes > 1024)
|
|
121
|
+
throw new SecretError("--generate must be a whole byte count from 1 to 1024");
|
|
122
|
+
return { ...files, ...Object.fromEntries(rest.map((key) => [key, random(bytes).toString("base64url")])) };
|
|
123
|
+
}
|
|
124
|
+
if (typeof stdin === "function") return { ...files, ...stdinValues(rest, stdin()) };
|
|
125
|
+
const source = stdin ?? process.stdin;
|
|
126
|
+
if (source.isTTY) return { ...files, ...(await typedValues(rest, prompt)) };
|
|
127
|
+
return { ...files, ...stdinValues(rest, fs.readFileSync(source.fd ?? 0, "utf8")) };
|
|
128
|
+
}
|
|
@@ -7,22 +7,83 @@ import { decrypt as sopsDecrypt } from "../api/sops/decrypt.mjs";
|
|
|
7
7
|
import { seal as sopsSeal } from "../api/sops/seal.mjs";
|
|
8
8
|
import { invocationDir } from "../lib/roots.mjs";
|
|
9
9
|
import { envelopeOf } from "./secret-document.mjs";
|
|
10
|
-
import {
|
|
10
|
+
import { configuredRecipients, localAgeIdentity, secretTools } from "./secret-init.mjs";
|
|
11
11
|
import { outsideRepository, secretRoots } from "./secret-files.mjs";
|
|
12
12
|
import { SecretError, publicStorage } from "./secret-errors.mjs";
|
|
13
13
|
|
|
14
14
|
const sopsInvocation = Object.freeze({ runProgram, resolveRealTool });
|
|
15
15
|
const AGE_IDENTITY_NAME = /^SOPS_AGE_KEY(?:_FILE)?$/i;
|
|
16
16
|
|
|
17
|
-
/** The
|
|
17
|
+
/** The environment with no age identity selected: what a seal runs with. */
|
|
18
|
+
const withoutIdentity = (env) =>
|
|
19
|
+
Object.fromEntries(Object.entries(env).filter(([name]) => !AGE_IDENTITY_NAME.test(name)));
|
|
20
|
+
|
|
21
|
+
/** The KEY map of sops' JSON output: a flat object of string values, else refused naming no value. */
|
|
22
|
+
function exactMap(stdout) {
|
|
23
|
+
let document;
|
|
24
|
+
try { document = JSON.parse(String(stdout)); }
|
|
25
|
+
catch { throw new SecretError("sops returned an invalid JSON document"); }
|
|
26
|
+
if (!document || typeof document !== "object" || Array.isArray(document))
|
|
27
|
+
throw new SecretError("sops returned an invalid JSON document");
|
|
28
|
+
const map = Object.create(null);
|
|
29
|
+
for (const [key, value] of Object.entries(document)) {
|
|
30
|
+
if (typeof value !== "string") throw new SecretError(`${key} is not a string value; a member holds string values only`);
|
|
31
|
+
map[key] = value;
|
|
32
|
+
}
|
|
33
|
+
return map;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The public age recipients a sealed member names, read without decrypting; none when it cannot be read. */
|
|
37
|
+
function recipientsOf(file) {
|
|
38
|
+
try { return envelopeOf(fs.readFileSync(file, "utf8")).recipients; }
|
|
39
|
+
catch { return []; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The refusal of a decrypt whose selected identity is not one `file` is sealed to: names where the identity came from and the
|
|
44
|
+
* member's recipients, and the two ways out. It carries no value.
|
|
45
|
+
*/
|
|
46
|
+
function identityMismatch(repoRoot, identity, file) {
|
|
47
|
+
const recipients = recipientsOf(file);
|
|
48
|
+
return new SecretError(
|
|
49
|
+
`the age identity of ${identity.source ?? "the environment"} does not open ${path.basename(file)}, ` +
|
|
50
|
+
`sealed to ${recipients.join(", ") || "another recipient"}. Fix: put that recipient's age secret key in ${path.join(repoRoot, ".claude", "secret.env")} as SOPS_AGE_KEY, ` +
|
|
51
|
+
"or, when no one holds it any more, seal the member's keys again (remove it and set them) after .sops.yaml names this identity's recipient",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The refusal of an operation that must decrypt `file` with no age identity selected: it names the recipient the member is
|
|
57
|
+
* sealed to, every place looked, and the one line that fixes it. It carries no value.
|
|
58
|
+
*/
|
|
59
|
+
function identityMissing(repoRoot, identity, file) {
|
|
60
|
+
const recipients = recipientsOf(file);
|
|
61
|
+
const sealedTo = recipients.length ? `, sealed to ${recipients.join(", ")}` : "";
|
|
62
|
+
const places = ["the environment (SOPS_AGE_KEY, SOPS_AGE_KEY_FILE)", ...identity.looked].join(", ");
|
|
63
|
+
return new SecretError(
|
|
64
|
+
`no age identity opens ${path.basename(file)}${sealedTo}; looked in ${places}. ` +
|
|
65
|
+
`Fix: add the line SOPS_AGE_KEY=<the age secret key of that recipient> to ${path.join(repoRoot, ".claude", "secret.env")} (git-ignored), ` +
|
|
66
|
+
"or set SOPS_AGE_KEY_FILE to the absolute path of its key file",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The default sops seam: the runtime's sops api, which finds the binary on this machine; the age identity is localAgeIdentity's
|
|
72
|
+
* (`host` its last source), then the legacy sops keys.txt. `identity` says where the age identity
|
|
73
|
+
* was found (`source`, null when nowhere) and where it was looked for; `requireIdentity(file)` refuses, before any value is
|
|
74
|
+
* read, an operation that must decrypt `file` with no identity.
|
|
75
|
+
*/
|
|
18
76
|
export function defaultSops(
|
|
19
77
|
env = process.env,
|
|
20
78
|
repoRoot = invocationDir(),
|
|
21
79
|
platform = process.platform,
|
|
22
80
|
tools = undefined,
|
|
81
|
+
host = null,
|
|
23
82
|
) {
|
|
24
83
|
const { sops: binary } = publicStorage(() => secretTools(env, tools));
|
|
25
|
-
const
|
|
84
|
+
const local = localAgeIdentity(repoRoot, env, tools, host);
|
|
85
|
+
const selectedEnv = local.env;
|
|
86
|
+
const identity = { source: local.source, looked: [...local.looked] };
|
|
26
87
|
const invocation = {
|
|
27
88
|
runProgram: tools?.runProgram ?? sopsInvocation.runProgram,
|
|
28
89
|
resolveRealTool: tools?.resolveRealTool ?? sopsInvocation.resolveRealTool,
|
|
@@ -49,11 +110,22 @@ export function defaultSops(
|
|
|
49
110
|
if (
|
|
50
111
|
!Object.keys(selectedEnv).some((name) => AGE_IDENTITY_NAME.test(name))
|
|
51
112
|
) {
|
|
52
|
-
const
|
|
53
|
-
checkIdentity(
|
|
54
|
-
|
|
113
|
+
const keys = secretRoots(env, platform).identity;
|
|
114
|
+
checkIdentity(keys);
|
|
115
|
+
identity.looked.push(keys);
|
|
116
|
+
if (fs.existsSync(keys)) {
|
|
117
|
+
selectedEnv.SOPS_AGE_KEY_FILE = keys;
|
|
118
|
+
identity.source = keys;
|
|
119
|
+
}
|
|
55
120
|
}
|
|
56
|
-
const
|
|
121
|
+
const requireIdentity = (file) => {
|
|
122
|
+
if (identity.source === null) throw identityMissing(repoRoot, identity, file);
|
|
123
|
+
};
|
|
124
|
+
const refuse = (what, result, file) => {
|
|
125
|
+
if (result.error?.identityRefusal === "identity-not-supplied" && file)
|
|
126
|
+
return identityMissing(repoRoot, identity, file);
|
|
127
|
+
if (result.error?.identityRefusal === "recipient-mismatch" && file)
|
|
128
|
+
return identityMismatch(repoRoot, identity, file);
|
|
57
129
|
if (result.error?.identityRefusal)
|
|
58
130
|
return Object.assign(new SecretError(result.error.message), {
|
|
59
131
|
identityRefusal: result.error.identityRefusal,
|
|
@@ -65,17 +137,26 @@ export function defaultSops(
|
|
|
65
137
|
);
|
|
66
138
|
};
|
|
67
139
|
return {
|
|
140
|
+
identity,
|
|
141
|
+
requireIdentity,
|
|
142
|
+
/**
|
|
143
|
+
* The KEY map of a member. A YAML or JSON member decrypts to JSON, so every value comes back byte for byte (a multi-line
|
|
144
|
+
* key, a trailing newline, a carriage return); a legacy dotenv member decrypts to dotenv, whose LF is escaped.
|
|
145
|
+
*/
|
|
68
146
|
decrypt(file, format) {
|
|
147
|
+
const exact = format === "yaml" || format === "json";
|
|
69
148
|
const result = sopsDecrypt(
|
|
70
149
|
binary,
|
|
71
|
-
["decrypt", "--input-type", format, "--output-type", "dotenv", file],
|
|
150
|
+
["decrypt", "--input-type", format, "--output-type", exact ? "json" : "dotenv", file],
|
|
72
151
|
{ env: selectedEnv, cwd: repoRoot, invocation },
|
|
73
152
|
);
|
|
74
153
|
if (result.status !== 0)
|
|
75
154
|
throw refuse(
|
|
76
155
|
`decrypt ${path.basename(file)}; is the age identity installed?`,
|
|
77
156
|
result,
|
|
157
|
+
file,
|
|
78
158
|
);
|
|
159
|
+
if (exact) return exactMap(result.stdout);
|
|
79
160
|
const map = Object.create(null);
|
|
80
161
|
for (const line of String(result.stdout).split(/\r?\n/u)) {
|
|
81
162
|
if (!line || line.startsWith("#")) continue;
|
|
@@ -109,6 +190,7 @@ export function defaultSops(
|
|
|
109
190
|
throw refuse(
|
|
110
191
|
`decrypt ${path.basename(file)}; is the age identity installed?`,
|
|
111
192
|
result,
|
|
193
|
+
file,
|
|
112
194
|
);
|
|
113
195
|
return String(result.stdout);
|
|
114
196
|
},
|
|
@@ -118,10 +200,12 @@ export function defaultSops(
|
|
|
118
200
|
: publicStorage(() =>
|
|
119
201
|
configuredRecipients(repoRoot, request.filenameOverride),
|
|
120
202
|
);
|
|
203
|
+
// Sealing needs only public recipients, so it runs with no identity selected and may name several (an environment's
|
|
204
|
+
// server key and the owner key both).
|
|
121
205
|
const result = sopsSeal(
|
|
122
206
|
binary,
|
|
123
207
|
{ ...request, recipients },
|
|
124
|
-
{ env: selectedEnv, cwd: repoRoot, invocation },
|
|
208
|
+
{ env: withoutIdentity(selectedEnv), cwd: repoRoot, invocation },
|
|
125
209
|
);
|
|
126
210
|
if (result.status !== 0) throw refuse("seal the secret", result);
|
|
127
211
|
return result.stdout;
|
|
@@ -0,0 +1,315 @@
|
|
|
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
|
+
for (const entry of Array.isArray(spec?.secrets) ? spec.secrets : []) {
|
|
90
|
+
const member = entry?.member ?? /^secrets\/([a-z0-9-]+)/u.exec(entry?.where ?? "")?.[1];
|
|
91
|
+
if (member && !fs.existsSync(fileOf(directory, member)))
|
|
92
|
+
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` });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return findings;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const indentOf = (line) => /^ */u.exec(line)[0].length;
|
|
99
|
+
const meaningful = (line) => line.trim() !== "" && !line.trimStart().startsWith("#");
|
|
100
|
+
|
|
101
|
+
/** The [from, to) lines of the block under line `at`: every following line indented deeper, or blank, or a comment. */
|
|
102
|
+
function blockOf(lines, at) {
|
|
103
|
+
const indent = indentOf(lines[at]);
|
|
104
|
+
let to = at + 1;
|
|
105
|
+
let last = at;
|
|
106
|
+
for (; to < lines.length && (!meaningful(lines[to]) || indentOf(lines[to]) > indent); to += 1) if (meaningful(lines[to])) last = to;
|
|
107
|
+
return { from: at + 1, to: last + 1 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The line of `key:` among the direct children of a block, or -1. */
|
|
111
|
+
function childLine(lines, block, key) {
|
|
112
|
+
const first = lines.slice(block.from, block.to).find(meaningful);
|
|
113
|
+
if (first === undefined) return -1;
|
|
114
|
+
const indent = indentOf(first);
|
|
115
|
+
for (let at = block.from; at < block.to; at += 1)
|
|
116
|
+
if (meaningful(lines[at]) && indentOf(lines[at]) === indent && lines[at].trimStart().startsWith(`${key}:`)) return at;
|
|
117
|
+
return -1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Declares each of `keys` of `member` in environment `environment`'s `secrets:` of application-stacks.yaml, one entry per key
|
|
122
|
+
* (`name: <kebab of the key>, member, key, where: secrets/<member>`), so the key is materialized as its own `<KEY>_FILE`. A key
|
|
123
|
+
* already declared is left alone. Edits the text in place (comments and order kept) and proves the result parses to the
|
|
124
|
+
* expected document; a shape this edit cannot place returns the keys to declare by hand.
|
|
125
|
+
*/
|
|
126
|
+
export function declareKeys(repoRoot, environment, member, keys) {
|
|
127
|
+
const file = path.join(repoRoot, ".starcistacks", "application-stacks.yaml");
|
|
128
|
+
if (!fs.existsSync(file)) return { declared: [], byHand: [] };
|
|
129
|
+
const text = fs.readFileSync(file, "utf8");
|
|
130
|
+
const document = parseYaml(text) ?? {};
|
|
131
|
+
const spec = document.environments?.[environment];
|
|
132
|
+
if (!spec) return { declared: [], byHand: [] };
|
|
133
|
+
const missing = keys.filter((key) => !declaredKeys(spec, member, keys).includes(key));
|
|
134
|
+
if (!missing.length) return { declared: [], byHand: [] };
|
|
135
|
+
// A swarm environment's secret is a versioned external secret its stack file names (runtimeName, deploy grants): its
|
|
136
|
+
// declaration is wired with the stack, not appended here.
|
|
137
|
+
if (spec.runtime === "docker-swarm") return { declared: [], byHand: missing };
|
|
138
|
+
const sibling = Object.values(document.environments).flatMap((each) => (Array.isArray(each?.secrets) ? each.secrets : []))[0];
|
|
139
|
+
const entries = missing.map((key) => ({
|
|
140
|
+
name: key.toLowerCase().replaceAll("_", "-"), member, key, where: `secrets/${member}`,
|
|
141
|
+
recipientPolicy: "its environment's rule of the app's managed .sops.yaml",
|
|
142
|
+
keyCustody: sibling?.keyCustody ?? "SOPS_AGE_KEY in the harness secret.env",
|
|
143
|
+
}));
|
|
144
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
145
|
+
const lines = text.replace(/\r\n/gu, "\n").split("\n");
|
|
146
|
+
const root = lines.findIndex((line) => /^environments:\s*(?:#.*)?$/u.test(line));
|
|
147
|
+
const envLine = root < 0 ? -1 : childLine(lines, blockOf(lines, root), environment);
|
|
148
|
+
const secrets = envLine < 0 ? -1 : childLine(lines, blockOf(lines, envLine), "secrets");
|
|
149
|
+
if (secrets < 0 || !/^ *secrets:\s*(?:\[\s*\])?\s*(?:#.*)?$/u.test(lines[secrets])) return { declared: [], byHand: missing };
|
|
150
|
+
const pad = indentOf(lines[secrets]);
|
|
151
|
+
lines[secrets] = `${" ".repeat(pad)}secrets:`;
|
|
152
|
+
const items = blockOf(lines, secrets);
|
|
153
|
+
const firstItem = lines.slice(items.from, items.to).find(meaningful);
|
|
154
|
+
const dash = " ".repeat(firstItem === undefined ? pad + 2 : indentOf(firstItem));
|
|
155
|
+
const rendered = entries.flatMap((entry) => Object.entries(entry).map(([name, value], index) => `${dash}${index ? " " : "- "}${name}: ${JSON.stringify(value)}`));
|
|
156
|
+
lines.splice(items.to, 0, ...rendered);
|
|
157
|
+
const expected = structuredClone(document);
|
|
158
|
+
const target = expected.environments[environment];
|
|
159
|
+
target.secrets = [...(Array.isArray(target.secrets) ? target.secrets : []), ...entries];
|
|
160
|
+
const edited = lines.join(newline);
|
|
161
|
+
let parsed;
|
|
162
|
+
try { parsed = parseYaml(edited); } catch { parsed = undefined; }
|
|
163
|
+
if (JSON.stringify(parsed) !== JSON.stringify(expected)) return { declared: [], byHand: missing };
|
|
164
|
+
fs.writeFileSync(file, edited);
|
|
165
|
+
return { declared: missing, byHand: [] };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** `starci app secret audit [<env>]`: each deviation on its own line and exit 1, else one clean line and exit 0. */
|
|
169
|
+
export function printAudit(repoRoot, environment, stdout) {
|
|
170
|
+
const findings = auditSecrets(repoRoot, environment);
|
|
171
|
+
for (const finding of findings) stdout(`${finding.code} ${finding.env}/${finding.member}: ${finding.detail}\n`);
|
|
172
|
+
if (findings.length) return 1;
|
|
173
|
+
stdout(`app secrets follow the standard${environment ? ` in ${environment}` : ""}\n`);
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Seals `map` as member `<slug>.enc.yaml` to the recipients its environment's rule names; `map` holds every key it keeps. */
|
|
178
|
+
function sealFresh(directory, slug, map, sops) {
|
|
179
|
+
const file = path.join(directory, `${slug}${MEMBER_SUFFIX}`);
|
|
180
|
+
writeSecret({ file, values: map, age: [], sops });
|
|
181
|
+
return file;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function plannedMember(directory, member) {
|
|
185
|
+
const file = fileOf(directory, member);
|
|
186
|
+
if (!fs.existsSync(file)) throw new SecretError(`${member} is not a sealed member of this environment`);
|
|
187
|
+
return { file, envelope: envelopeOf(fs.readFileSync(file, "utf8")) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Each target of a move: its keys, whether it is the source member itself (`own`, re-sealed in place) and whether it is another
|
|
192
|
+
* member that already exists (`existing`, merged into only with --into-existing, never a legacy one). Refuses before any read.
|
|
193
|
+
*/
|
|
194
|
+
function plannedTargets({ directory, environment, file, groups, intoExisting, verb }) {
|
|
195
|
+
return [...groups].map(([target, keys]) => {
|
|
196
|
+
const resolved = fileOf(directory, target);
|
|
197
|
+
const own = resolved === file && !isLegacyName(path.basename(file));
|
|
198
|
+
const existing = resolved !== file && fs.existsSync(resolved);
|
|
199
|
+
const label = `${environment}/${path.basename(resolved)}`;
|
|
200
|
+
if (existing && isLegacyName(path.basename(resolved))) throw new SecretError(`${label} is a legacy member; migrate it first, then merge into it`);
|
|
201
|
+
if (existing && !intoExisting) throw new SecretError(`${label} already exists; ${verb} merges into it only with --into-existing`);
|
|
202
|
+
const held = existing ? envelopeOf(fs.readFileSync(resolved, "utf8")).keys : [];
|
|
203
|
+
const overlap = keys.filter((key) => held.includes(key));
|
|
204
|
+
return { target, keys, own, existing, overlap, file: existing ? resolved : path.join(directory, `${target}${MEMBER_SUFFIX}`) };
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const planText = (environment, targets) => targets.map(({ target, keys, existing, overlap }) => {
|
|
209
|
+
const merge = overlap.length ? `; it already holds ${keyList(overlap)}, which must be identical` : "";
|
|
210
|
+
return `${environment}/${target}${MEMBER_SUFFIX} (${keyList(keys)})${existing ? ` merged into the existing member${merge}` : ""}`;
|
|
211
|
+
}).join(", ");
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Moves the keys of the source member `file` into `targets`, decrypting in memory only. Every existing target is opened and
|
|
215
|
+
* checked first: a key it already holds with another value refuses the whole move, naming the keys and never a value. Then
|
|
216
|
+
* each target is written once, and only after every target is written is the source removed (or, for an own target, the
|
|
217
|
+
* re-sealed copy staged beside it is moved over it), so no key is ever held by no member.
|
|
218
|
+
*/
|
|
219
|
+
function moveKeys({ directory, environment, file, envelope, targets, member, sops }) {
|
|
220
|
+
const seam = sops();
|
|
221
|
+
const map = seam.decrypt(file, envelope.format);
|
|
222
|
+
validateMap(map);
|
|
223
|
+
if (!sameSet(Object.keys(map), envelope.keys)) throw new SecretError("sops returned keys that differ from the envelope");
|
|
224
|
+
const writes = targets.map((target) => {
|
|
225
|
+
const values = Object.fromEntries(target.keys.map((key) => [key, map[key]]));
|
|
226
|
+
if (!target.existing) return { target, values, colliding: [] };
|
|
227
|
+
const held = seam.decrypt(target.file, envelopeOf(fs.readFileSync(target.file, "utf8")).format);
|
|
228
|
+
validateMap(held);
|
|
229
|
+
const colliding = target.keys.filter((key) => Object.hasOwn(held, key) && held[key] !== values[key]);
|
|
230
|
+
return { target, values: { ...held, ...values }, colliding };
|
|
231
|
+
});
|
|
232
|
+
const collisions = writes.filter((write) => write.colliding.length);
|
|
233
|
+
if (collisions.length)
|
|
234
|
+
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`);
|
|
235
|
+
const staging = `${member}-staged`;
|
|
236
|
+
if (targets.some((target) => target.own) && fs.existsSync(fileOf(directory, staging)))
|
|
237
|
+
throw new SecretError(`${environment}/${staging}${MEMBER_SUFFIX} exists; remove it before moving ${member}`);
|
|
238
|
+
let staged = null;
|
|
239
|
+
for (const { target, values } of writes) {
|
|
240
|
+
if (target.own) staged = sealFresh(directory, staging, values, seam);
|
|
241
|
+
else writeSecret({ file: target.file, values, age: [], sops: seam });
|
|
242
|
+
}
|
|
243
|
+
if (staged) fs.renameSync(staged, file);
|
|
244
|
+
else deleteSealed(file);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The `--group-file` YAML map (`KEY: member`) and the `--group KEY=member` entries as one map; a key named twice is refused. */
|
|
248
|
+
export function groupMap(entries = [], groupFile = null) {
|
|
249
|
+
const pairs = [];
|
|
250
|
+
if (groupFile) {
|
|
251
|
+
if (!fs.existsSync(groupFile)) throw new SecretError(`--group-file ${groupFile} does not exist`);
|
|
252
|
+
let document;
|
|
253
|
+
try { document = parseYaml(fs.readFileSync(groupFile, "utf8")); } catch { document = undefined; }
|
|
254
|
+
if (!document || typeof document !== "object" || Array.isArray(document) || Object.values(document).some((value) => typeof value !== "string"))
|
|
255
|
+
throw new SecretError(`--group-file ${groupFile} must be a YAML map of KEY: member`);
|
|
256
|
+
pairs.push(...Object.entries(document));
|
|
257
|
+
}
|
|
258
|
+
for (const entry of entries) {
|
|
259
|
+
const at = entry.indexOf("=");
|
|
260
|
+
if (at < 1 || !entry.slice(at + 1)) throw new SecretError("--group takes KEY=member");
|
|
261
|
+
pairs.push([entry.slice(0, at), entry.slice(at + 1)]);
|
|
262
|
+
}
|
|
263
|
+
const groups = new Map();
|
|
264
|
+
for (const [key, member] of pairs) {
|
|
265
|
+
if (groups.has(key)) throw new SecretError(`--group names ${key} twice`);
|
|
266
|
+
groups.set(key, member);
|
|
267
|
+
}
|
|
268
|
+
return groups;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* `migrate <env> <member> [--to <slug>] [--into-existing] [--execute]`: moves a member (a legacy dotenv `.enc`, one sealed to
|
|
273
|
+
* other recipients than its rule, or one whose keys belong to another member) to `<slug>.enc.yaml` sealed to the environment's
|
|
274
|
+
* recipients, merging into an existing `<slug>` only with --into-existing, then removes the old file. A dry run until --execute.
|
|
275
|
+
*/
|
|
276
|
+
export function migrateMember({ directory, environment, member, to, intoExisting, execute, sops, stdout }) {
|
|
277
|
+
const { file, envelope } = plannedMember(directory, member);
|
|
278
|
+
const targets = plannedTargets({ directory, environment, file, groups: new Map([[to ?? member, envelope.keys]]), intoExisting, verb: "migrate" });
|
|
279
|
+
const plan = `${environment}/${path.basename(file)} to ${planText(environment, targets)}`;
|
|
280
|
+
if (!execute) {
|
|
281
|
+
stdout(`dry run: would move ${plan}; add --execute\n`);
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
moveKeys({ directory, environment, file, envelope, targets, member, sops });
|
|
285
|
+
stdout(`moved ${plan}\n`);
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* `split <env> <member> [--group KEY=member ...] [--group-file <yaml>] [--into-existing] [--execute]`: divides a member into one
|
|
291
|
+
* `<member>.enc.yaml` per group, each sealed to the environment's recipients, then removes the old file. A key's group is its
|
|
292
|
+
* --group member, else its provider (providerOf); an existing member is merged into only with --into-existing. A dry run until
|
|
293
|
+
* --execute.
|
|
294
|
+
*/
|
|
295
|
+
export function splitMember({ directory, environment, member, groups: chosen = new Map(), intoExisting, execute, sops, stdout }) {
|
|
296
|
+
const { file, envelope } = plannedMember(directory, member);
|
|
297
|
+
const stray = [...chosen.keys()].filter((key) => !envelope.keys.includes(key));
|
|
298
|
+
if (stray.length) throw new SecretError(`--group names ${keyList(stray)}, which ${environment}/${member} does not hold`);
|
|
299
|
+
const groups = new Map();
|
|
300
|
+
for (const key of envelope.keys) {
|
|
301
|
+
const group = chosen.get(key) ?? providerOf(key);
|
|
302
|
+
groups.set(group, [...(groups.get(group) ?? []), key]);
|
|
303
|
+
}
|
|
304
|
+
if (groups.size < 2 && groups.has(member))
|
|
305
|
+
throw new SecretError(`${environment}/${member} holds one group's keys; nothing to split (migrate renames a member)`);
|
|
306
|
+
const targets = plannedTargets({ directory, environment, file, groups, intoExisting, verb: "split" });
|
|
307
|
+
const plan = planText(environment, targets);
|
|
308
|
+
if (!execute) {
|
|
309
|
+
stdout(`dry run: would split ${environment}/${path.basename(file)} into ${plan}; add --execute\n`);
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
moveKeys({ directory, environment, file, envelope, targets, member, sops });
|
|
313
|
+
stdout(`split ${environment}/${path.basename(file)} into ${plan}\n`);
|
|
314
|
+
return 0;
|
|
315
|
+
}
|