@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,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
|
+
}
|
|
@@ -5,24 +5,87 @@ import { runProgram } from "../api/process/run-program.mjs";
|
|
|
5
5
|
import { resolveRealTool } from "../api/process/resolve-real-tool.mjs";
|
|
6
6
|
import { decrypt as sopsDecrypt } from "../api/sops/decrypt.mjs";
|
|
7
7
|
import { seal as sopsSeal } from "../api/sops/seal.mjs";
|
|
8
|
+
import { updateKeys as sopsUpdateKeys } from "../api/sops/update-keys.mjs";
|
|
9
|
+
import { isLegacyName } from "../lib/sealed-member.mjs";
|
|
8
10
|
import { invocationDir } from "../lib/roots.mjs";
|
|
9
11
|
import { envelopeOf } from "./secret-document.mjs";
|
|
10
|
-
import {
|
|
12
|
+
import { configuredRecipients, localAgeIdentity, secretTools } from "./secret-init.mjs";
|
|
11
13
|
import { outsideRepository, secretRoots } from "./secret-files.mjs";
|
|
12
14
|
import { SecretError, publicStorage } from "./secret-errors.mjs";
|
|
13
15
|
|
|
14
16
|
const sopsInvocation = Object.freeze({ runProgram, resolveRealTool });
|
|
15
17
|
const AGE_IDENTITY_NAME = /^SOPS_AGE_KEY(?:_FILE)?$/i;
|
|
16
18
|
|
|
17
|
-
/** The
|
|
19
|
+
/** The environment with no age identity selected: what a seal runs with. */
|
|
20
|
+
const withoutIdentity = (env) =>
|
|
21
|
+
Object.fromEntries(Object.entries(env).filter(([name]) => !AGE_IDENTITY_NAME.test(name)));
|
|
22
|
+
|
|
23
|
+
/** The KEY map of sops' JSON output: a flat object of string values, else refused naming no value. */
|
|
24
|
+
function exactMap(stdout) {
|
|
25
|
+
let document;
|
|
26
|
+
try { document = JSON.parse(String(stdout)); }
|
|
27
|
+
catch { throw new SecretError("sops returned an invalid JSON document"); }
|
|
28
|
+
if (!document || typeof document !== "object" || Array.isArray(document))
|
|
29
|
+
throw new SecretError("sops returned an invalid JSON document");
|
|
30
|
+
const map = Object.create(null);
|
|
31
|
+
for (const [key, value] of Object.entries(document)) {
|
|
32
|
+
if (typeof value !== "string") throw new SecretError(`${key} is not a string value; a member holds string values only`);
|
|
33
|
+
map[key] = value;
|
|
34
|
+
}
|
|
35
|
+
return map;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The public age recipients a sealed member names, read without decrypting; none when it cannot be read. */
|
|
39
|
+
function recipientsOf(file) {
|
|
40
|
+
try { return envelopeOf(fs.readFileSync(file, "utf8")).recipients; }
|
|
41
|
+
catch { return []; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The refusal of a decrypt whose selected identity is not one `file` is sealed to: names where the identity came from and the
|
|
46
|
+
* member's recipients, and the two ways out. It carries no value.
|
|
47
|
+
*/
|
|
48
|
+
function identityMismatch(repoRoot, identity, file) {
|
|
49
|
+
const recipients = recipientsOf(file);
|
|
50
|
+
return new SecretError(
|
|
51
|
+
`the age identity of ${identity.source ?? "the environment"} does not open ${path.basename(file)}, ` +
|
|
52
|
+
`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, ` +
|
|
53
|
+
"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",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The refusal of an operation that must decrypt `file` with no age identity selected: it names the recipient the member is
|
|
59
|
+
* sealed to, every place looked, and the one line that fixes it. It carries no value.
|
|
60
|
+
*/
|
|
61
|
+
function identityMissing(repoRoot, identity, file) {
|
|
62
|
+
const recipients = recipientsOf(file);
|
|
63
|
+
const sealedTo = recipients.length ? `, sealed to ${recipients.join(", ")}` : "";
|
|
64
|
+
const places = ["the environment (SOPS_AGE_KEY, SOPS_AGE_KEY_FILE)", ...identity.looked].join(", ");
|
|
65
|
+
return new SecretError(
|
|
66
|
+
`no age identity opens ${path.basename(file)}${sealedTo}; looked in ${places}. ` +
|
|
67
|
+
`Fix: add the line SOPS_AGE_KEY=<the age secret key of that recipient> to ${path.join(repoRoot, ".claude", "secret.env")} (git-ignored), ` +
|
|
68
|
+
"or set SOPS_AGE_KEY_FILE to the absolute path of its key file",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The default sops seam: the runtime's sops api, which finds the binary on this machine; the age identity is localAgeIdentity's
|
|
74
|
+
* (`host` its last source), then the legacy sops keys.txt. `identity` says where the age identity
|
|
75
|
+
* was found (`source`, null when nowhere) and where it was looked for; `requireIdentity(file)` refuses, before any value is
|
|
76
|
+
* read, an operation that must decrypt `file` with no identity.
|
|
77
|
+
*/
|
|
18
78
|
export function defaultSops(
|
|
19
79
|
env = process.env,
|
|
20
80
|
repoRoot = invocationDir(),
|
|
21
81
|
platform = process.platform,
|
|
22
82
|
tools = undefined,
|
|
83
|
+
host = null,
|
|
23
84
|
) {
|
|
24
85
|
const { sops: binary } = publicStorage(() => secretTools(env, tools));
|
|
25
|
-
const
|
|
86
|
+
const local = localAgeIdentity(repoRoot, env, tools, host);
|
|
87
|
+
const selectedEnv = local.env;
|
|
88
|
+
const identity = { source: local.source, looked: [...local.looked] };
|
|
26
89
|
const invocation = {
|
|
27
90
|
runProgram: tools?.runProgram ?? sopsInvocation.runProgram,
|
|
28
91
|
resolveRealTool: tools?.resolveRealTool ?? sopsInvocation.resolveRealTool,
|
|
@@ -49,11 +112,22 @@ export function defaultSops(
|
|
|
49
112
|
if (
|
|
50
113
|
!Object.keys(selectedEnv).some((name) => AGE_IDENTITY_NAME.test(name))
|
|
51
114
|
) {
|
|
52
|
-
const
|
|
53
|
-
checkIdentity(
|
|
54
|
-
|
|
115
|
+
const keys = secretRoots(env, platform).identity;
|
|
116
|
+
checkIdentity(keys);
|
|
117
|
+
identity.looked.push(keys);
|
|
118
|
+
if (fs.existsSync(keys)) {
|
|
119
|
+
selectedEnv.SOPS_AGE_KEY_FILE = keys;
|
|
120
|
+
identity.source = keys;
|
|
121
|
+
}
|
|
55
122
|
}
|
|
56
|
-
const
|
|
123
|
+
const requireIdentity = (file) => {
|
|
124
|
+
if (identity.source === null) throw identityMissing(repoRoot, identity, file);
|
|
125
|
+
};
|
|
126
|
+
const refuse = (what, result, file) => {
|
|
127
|
+
if (result.error?.identityRefusal === "identity-not-supplied" && file)
|
|
128
|
+
return identityMissing(repoRoot, identity, file);
|
|
129
|
+
if (result.error?.identityRefusal === "recipient-mismatch" && file)
|
|
130
|
+
return identityMismatch(repoRoot, identity, file);
|
|
57
131
|
if (result.error?.identityRefusal)
|
|
58
132
|
return Object.assign(new SecretError(result.error.message), {
|
|
59
133
|
identityRefusal: result.error.identityRefusal,
|
|
@@ -65,17 +139,26 @@ export function defaultSops(
|
|
|
65
139
|
);
|
|
66
140
|
};
|
|
67
141
|
return {
|
|
142
|
+
identity,
|
|
143
|
+
requireIdentity,
|
|
144
|
+
/**
|
|
145
|
+
* 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
|
|
146
|
+
* key, a trailing newline, a carriage return); a legacy dotenv member decrypts to dotenv, whose LF is escaped.
|
|
147
|
+
*/
|
|
68
148
|
decrypt(file, format) {
|
|
149
|
+
const exact = format === "yaml" || format === "json";
|
|
69
150
|
const result = sopsDecrypt(
|
|
70
151
|
binary,
|
|
71
|
-
["decrypt", "--input-type", format, "--output-type", "dotenv", file],
|
|
152
|
+
["decrypt", "--input-type", format, "--output-type", exact ? "json" : "dotenv", file],
|
|
72
153
|
{ env: selectedEnv, cwd: repoRoot, invocation },
|
|
73
154
|
);
|
|
74
155
|
if (result.status !== 0)
|
|
75
156
|
throw refuse(
|
|
76
157
|
`decrypt ${path.basename(file)}; is the age identity installed?`,
|
|
77
158
|
result,
|
|
159
|
+
file,
|
|
78
160
|
);
|
|
161
|
+
if (exact) return exactMap(result.stdout);
|
|
79
162
|
const map = Object.create(null);
|
|
80
163
|
for (const line of String(result.stdout).split(/\r?\n/u)) {
|
|
81
164
|
if (!line || line.startsWith("#")) continue;
|
|
@@ -109,19 +192,35 @@ export function defaultSops(
|
|
|
109
192
|
throw refuse(
|
|
110
193
|
`decrypt ${path.basename(file)}; is the age identity installed?`,
|
|
111
194
|
result,
|
|
195
|
+
file,
|
|
112
196
|
);
|
|
113
197
|
return String(result.stdout);
|
|
114
198
|
},
|
|
199
|
+
/**
|
|
200
|
+
* Wraps member `file` again to the recipients its environment's rule of the app's .sops.yaml names (sops updatekeys): the
|
|
201
|
+
* selected identity opens the data key only, no value is decrypted, nothing is printed.
|
|
202
|
+
*/
|
|
203
|
+
updateKeys(file) {
|
|
204
|
+
const result = sopsUpdateKeys(
|
|
205
|
+
binary,
|
|
206
|
+
{ file: path.resolve(file), config: path.join(path.resolve(repoRoot), ".sops.yaml"), inputType: isLegacyName(file) ? "dotenv" : "yaml" },
|
|
207
|
+
{ env: selectedEnv, cwd: repoRoot, invocation },
|
|
208
|
+
);
|
|
209
|
+
if (result.status !== 0)
|
|
210
|
+
throw refuse(`update the keys of ${path.basename(file)}; is the age identity a recipient of it?`, result, file);
|
|
211
|
+
},
|
|
115
212
|
seal(request) {
|
|
116
213
|
const recipients = request.recipients.length
|
|
117
214
|
? request.recipients
|
|
118
215
|
: publicStorage(() =>
|
|
119
216
|
configuredRecipients(repoRoot, request.filenameOverride),
|
|
120
217
|
);
|
|
218
|
+
// Sealing needs only public recipients, so it runs with no identity selected and may name several (an environment's
|
|
219
|
+
// server key and the owner key both).
|
|
121
220
|
const result = sopsSeal(
|
|
122
221
|
binary,
|
|
123
222
|
{ ...request, recipients },
|
|
124
|
-
{ env: selectedEnv, cwd: repoRoot, invocation },
|
|
223
|
+
{ env: withoutIdentity(selectedEnv), cwd: repoRoot, invocation },
|
|
125
224
|
);
|
|
126
225
|
if (result.status !== 0) throw refuse("seal the secret", result);
|
|
127
226
|
return result.stdout;
|