@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
|
@@ -1,25 +1,35 @@
|
|
|
1
1
|
// secret.mjs - app secret init|set|get|unset|rename|remove|materialize|list|verify: sealed custody and private KEY files.
|
|
2
2
|
// SOPS plaintext travels on stdin and stays in memory; normal CLI output contains names and local file paths only.
|
|
3
|
+
// set seals one or more keys in one sops write: typed at a masked prompt on a terminal, KEY=value lines on redirected stdin
|
|
4
|
+
// (the whole stdin for one key), or --generate (scripts/hfs/secret-prompt.mjs).
|
|
3
5
|
// Every verb runs at the app root or --cwd. Exit 0 done, 2 refusal; --reveal is the only plaintext stdout path.
|
|
4
6
|
// unset, rename and remove edit one sealed member and are dry runs until --execute (scripts/hfs/secret-edit.mjs).
|
|
5
7
|
// verify decrypts each member in memory and names only keys and violated shapes, never a value (R249).
|
|
8
|
+
// server-key makes the deploy server's own age identity on the server and seals the environment to it (stack/server-key.mjs).
|
|
6
9
|
import fs from "node:fs";
|
|
7
10
|
import path from "node:path";
|
|
8
11
|
import { randomBytes } from "node:crypto";
|
|
9
12
|
import { invocationDir } from "../lib/roots.mjs";
|
|
10
|
-
import { initializeSecrets } from "./secret-init.mjs";
|
|
11
|
-
import {
|
|
13
|
+
import { environmentRecipients, initializeSecrets, ownerRecipient } from "./secret-init.mjs";
|
|
14
|
+
import { declareKeys, groupMap, migrateMember, printAudit, splitMember } from "./secret-standard.mjs";
|
|
15
|
+
import { envelopeOf, validateKey } from "./secret-document.mjs";
|
|
12
16
|
import { defaultSops } from "./secret-sops.mjs";
|
|
13
17
|
import { fileOf, secretsDirectory, writeSecret } from "./secret-store.mjs";
|
|
14
18
|
import { getSecret, listSecrets, materializeSecrets } from "./secret-output.mjs";
|
|
15
19
|
import { SecretError, publicStorage, secretErrorMessage } from "./secret-errors.mjs";
|
|
16
20
|
import { editSecret, validateEditNames } from "./secret-edit.mjs";
|
|
17
21
|
import { verifySecrets } from "./rules/env-value-shape.mjs";
|
|
22
|
+
import { setValues } from "./secret-prompt.mjs";
|
|
23
|
+
import { stackContext } from "../../../stack/context.mjs";
|
|
24
|
+
import { StackError } from "../../../stack/errors.mjs";
|
|
25
|
+
import { serverKey } from "../../../stack/server-key.mjs";
|
|
18
26
|
|
|
19
27
|
export { envelopeOf, formatOf, plaintextOf } from "./secret-document.mjs";
|
|
20
28
|
export { setSecretValues } from "./secret-store.mjs";
|
|
21
29
|
|
|
22
|
-
const VERBS = new Set(["init", "list", "get", "set", "unset", "rename", "remove", "materialize", "verify"]);
|
|
30
|
+
const VERBS = new Set(["init", "list", "get", "set", "unset", "rename", "remove", "materialize", "verify", "recipients", "migrate", "split", "audit", "server-key"]);
|
|
31
|
+
const MOVE_VERBS = new Set(["migrate", "split"]);
|
|
32
|
+
const LIST_FLAGS = ["--add", "--remove", "--from-file", "--group"];
|
|
23
33
|
const EDIT_VERBS = new Set(["unset", "rename", "remove"]);
|
|
24
34
|
|
|
25
35
|
function valueOption(opts, flag, value) {
|
|
@@ -35,9 +45,13 @@ function parse(argv) {
|
|
|
35
45
|
for (let i = 0; i < argv.length; i += 1) {
|
|
36
46
|
const arg = argv[i];
|
|
37
47
|
const [flag, inline] = arg.split(/=(.*)/su);
|
|
38
|
-
if (
|
|
48
|
+
if (LIST_FLAGS.includes(flag)) {
|
|
49
|
+
const value = inline ?? argv[++i];
|
|
50
|
+
if (value === undefined || value.startsWith("--")) throw new SecretError(`${flag} needs a value`);
|
|
51
|
+
(opts[flag.slice(2)] ??= []).push(value);
|
|
52
|
+
} else if (["--cwd", "--generate", "--to", "--group-file", "--backup", "--restore"].includes(flag)) {
|
|
39
53
|
valueOption(opts, flag, inline ?? argv[++i]);
|
|
40
|
-
} else if (["--to-file", "--reveal", "--execute", "--force-nonempty"].includes(flag)) {
|
|
54
|
+
} else if (["--to-file", "--reveal", "--execute", "--force-nonempty", "--into-existing", "--rotate"].includes(flag)) {
|
|
41
55
|
if (inline !== undefined && !["true", "false"].includes(inline))
|
|
42
56
|
throw new SecretError(`${flag} expects true or false`);
|
|
43
57
|
opts[flag.slice(2)] = inline !== "false";
|
|
@@ -48,27 +62,31 @@ function parse(argv) {
|
|
|
48
62
|
return opts;
|
|
49
63
|
}
|
|
50
64
|
|
|
51
|
-
const readStdin = () => {
|
|
52
|
-
if (process.stdin.isTTY)
|
|
53
|
-
throw new SecretError(
|
|
54
|
-
"set reads a value from redirected stdin; use --generate <bytes> to generate one",
|
|
55
|
-
);
|
|
56
|
-
return fs.readFileSync(0, "utf8");
|
|
57
|
-
};
|
|
58
|
-
|
|
59
65
|
function usageArguments(verb) {
|
|
60
66
|
if (verb === "init") return "";
|
|
61
|
-
if (verb === "set") return "<env> <slug> <KEY> [--generate <bytes>]";
|
|
67
|
+
if (verb === "set") return "<env> <slug> <KEY> [KEY ...] [--generate <bytes>]";
|
|
62
68
|
if (verb === "get") return "<env> <slug> [KEY] --to-file [--reveal]";
|
|
63
69
|
if (verb === "unset") return "<env> <slug> <KEY> [--execute]";
|
|
64
70
|
if (verb === "rename") return "<env> <slug> <OLD> <NEW> [--execute]";
|
|
65
71
|
if (verb === "remove") return "<env> <slug> [--execute] [--force-nonempty]";
|
|
72
|
+
if (verb === "recipients") return "<env> [--add <age1...|owner>] [--remove <age1...|owner>]";
|
|
73
|
+
if (verb === "migrate") return "<env> <slug> [--to <slug>] [--into-existing] [--execute]";
|
|
74
|
+
if (verb === "split") return "<env> <slug> [--group KEY=member ...] [--group-file <yaml>] [--into-existing] [--execute]";
|
|
75
|
+
if (verb === "audit") return "[<env>]";
|
|
76
|
+
if (verb === "server-key") return "<env> [--rotate | --backup github | --restore github] [--execute]";
|
|
66
77
|
return "<env>";
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
function editOptionsFor(verb, opts) {
|
|
70
|
-
if (opts.execute !== undefined && !EDIT_VERBS.has(verb))
|
|
71
|
-
throw new SecretError("--execute is only valid for unset, rename and
|
|
81
|
+
if (opts.execute !== undefined && !EDIT_VERBS.has(verb) && !MOVE_VERBS.has(verb) && verb !== "server-key")
|
|
82
|
+
throw new SecretError("--execute is only valid for unset, rename, remove, migrate, split and server-key");
|
|
83
|
+
if ((opts.rotate !== undefined || opts.backup !== undefined || opts.restore !== undefined) && verb !== "server-key")
|
|
84
|
+
throw new SecretError("--rotate, --backup and --restore are only valid for server-key");
|
|
85
|
+
if (opts.to !== undefined && verb !== "migrate") throw new SecretError("--to is only valid for migrate");
|
|
86
|
+
if ((opts.group || opts["group-file"] !== undefined) && verb !== "split") throw new SecretError("--group and --group-file are only valid for split");
|
|
87
|
+
if (opts["into-existing"] !== undefined && !MOVE_VERBS.has(verb)) throw new SecretError("--into-existing is only valid for migrate and split");
|
|
88
|
+
if ((opts.add || opts.remove) && verb !== "recipients") throw new SecretError("--add and --remove are only valid for recipients");
|
|
89
|
+
if (opts["from-file"] && verb !== "set") throw new SecretError("--from-file is only valid for set");
|
|
72
90
|
if (opts["force-nonempty"] !== undefined && verb !== "remove")
|
|
73
91
|
throw new SecretError("--force-nonempty is only valid for remove");
|
|
74
92
|
}
|
|
@@ -90,11 +108,16 @@ function optionsFor(verb, opts) {
|
|
|
90
108
|
list: [1, 1],
|
|
91
109
|
materialize: [1, 1],
|
|
92
110
|
verify: [1, 1],
|
|
93
|
-
set: [3,
|
|
111
|
+
set: [3, Infinity],
|
|
94
112
|
get: [2, 3],
|
|
95
113
|
unset: [3, 3],
|
|
96
114
|
rename: [4, 4],
|
|
97
115
|
remove: [2, 2],
|
|
116
|
+
recipients: [1, 1],
|
|
117
|
+
migrate: [2, 2],
|
|
118
|
+
split: [2, 2],
|
|
119
|
+
audit: [0, 1],
|
|
120
|
+
"server-key": [1, 1],
|
|
98
121
|
}[verb];
|
|
99
122
|
if (count < bounds[0] || count > bounds[1])
|
|
100
123
|
throw new SecretError(
|
|
@@ -105,70 +128,136 @@ function optionsFor(verb, opts) {
|
|
|
105
128
|
outputOptionsFor(verb, opts);
|
|
106
129
|
editOptionsFor(verb, opts);
|
|
107
130
|
if (EDIT_VERBS.has(verb)) validateEditNames(verb, opts.positional.slice(2));
|
|
108
|
-
if (verb === "set")
|
|
131
|
+
if (verb === "set") {
|
|
132
|
+
const keys = opts.positional.slice(2);
|
|
133
|
+
for (const key of keys) validateKey(key);
|
|
134
|
+
const twice = keys.find((key, index) => keys.indexOf(key) !== index);
|
|
135
|
+
if (twice) throw new SecretError(`set names ${twice} twice`);
|
|
136
|
+
}
|
|
109
137
|
if (verb === "get" && opts.positional[2] !== undefined)
|
|
110
138
|
validateKey(opts.positional[2]);
|
|
111
139
|
}
|
|
112
140
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (typeof stdin !== "function" && stdin?.isTTY)
|
|
124
|
-
throw new SecretError("set requires redirected stdin");
|
|
125
|
-
value = String(
|
|
126
|
-
typeof stdin === "function"
|
|
127
|
-
? stdin()
|
|
128
|
-
: fs.readFileSync(stdin?.fd ?? 0, "utf8"),
|
|
129
|
-
).replace(/\r?\n$/u, "");
|
|
141
|
+
/** The `--from-file KEY=path` values of set: each a named key, read byte for byte by setValues. */
|
|
142
|
+
function fromFiles(entries, keys) {
|
|
143
|
+
const files = {};
|
|
144
|
+
for (const entry of entries ?? []) {
|
|
145
|
+
const at = entry.indexOf("=");
|
|
146
|
+
const key = entry.slice(0, at);
|
|
147
|
+
if (at < 1 || !entry.slice(at + 1)) throw new SecretError("--from-file takes KEY=path");
|
|
148
|
+
if (!keys.includes(key)) throw new SecretError(`--from-file names ${key}, which this set does not`);
|
|
149
|
+
if (Object.hasOwn(files, key)) throw new SecretError(`--from-file names ${key} twice`);
|
|
150
|
+
files[key] = entry.slice(at + 1);
|
|
130
151
|
}
|
|
131
|
-
|
|
132
|
-
|
|
152
|
+
return files;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What `set` declared in the environment's stack `secrets:`, or the keys to declare by hand; nothing when all were declared. */
|
|
156
|
+
function declarationLine(environment, { declared, byHand }) {
|
|
157
|
+
if (declared.length) return `declared ${declared.join(", ")} in environments.${environment}.secrets of application-stacks.yaml\n`;
|
|
158
|
+
if (byHand.length) return `declare ${byHand.join(", ")} in environments.${environment}.secrets of application-stacks.yaml (this environment's shape is wired by hand)\n`;
|
|
159
|
+
return "";
|
|
133
160
|
}
|
|
134
161
|
|
|
135
|
-
/**
|
|
136
|
-
|
|
162
|
+
/** The app root of a sealed member: `<root>/.starcistacks/<env>/secrets/<slug>.enc`. */
|
|
163
|
+
const appRootOf = (file) => path.resolve(path.dirname(file), "..", "..", "..");
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The success line of set: the member and its keys and, for a member this set created, the age identity a later set needs to
|
|
167
|
+
* open it (the recipient it is sealed to, and where this machine holds that identity, or the fix when it holds none). Names
|
|
168
|
+
* and paths only.
|
|
169
|
+
*/
|
|
170
|
+
function sealedLine(environment, slug, keys, file, created, seam) {
|
|
171
|
+
const head = `sealed ${environment}/${slug} (${keys.join(", ")})`;
|
|
172
|
+
if (!created) return `${head}\n`;
|
|
173
|
+
let recipients = [];
|
|
174
|
+
try { recipients = envelopeOf(fs.readFileSync(file, "utf8")).recipients; }
|
|
175
|
+
catch { recipients = []; }
|
|
176
|
+
const of = recipients.length ? ` of ${recipients.join(", ")}` : "";
|
|
177
|
+
let held = "";
|
|
178
|
+
if (seam.identity?.source) held = `; this machine holds it in ${seam.identity.source}`;
|
|
179
|
+
else if (seam.identity)
|
|
180
|
+
held = `; this machine holds none: add SOPS_AGE_KEY to ${path.join(appRootOf(file), ".claude", "secret.env")} before the next set`;
|
|
181
|
+
return `${head}; a new member: adding keys to it later needs the age identity${of}${held}\n`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `server-key`: the deploy server's identity through the stack transport (`serverKeyIo`: { io, deps } of the specs), lines out. */
|
|
185
|
+
async function serverKeyVerb(repoRoot, environment, opts, { env, stdout, stderr, open, serverKeyIo }) {
|
|
186
|
+
const ctx = stackContext({ cwd: repoRoot, env, stdout, stderr, openSops: open, ...serverKeyIo.io });
|
|
187
|
+
try {
|
|
188
|
+
const result = await serverKey(ctx, environment, {
|
|
189
|
+
execute: opts.execute === true, rotate: opts.rotate === true, backup: opts.backup, restore: opts.restore,
|
|
190
|
+
}, serverKeyIo.deps ?? {});
|
|
191
|
+
for (const line of result.lines) stdout(`${line}\n`);
|
|
192
|
+
return result.ok ? 0 : 1;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (!(error instanceof StackError)) throw error;
|
|
195
|
+
stderr(`starci app secret: ${error.message}\n`);
|
|
196
|
+
return error.exit;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* All app secrets verbs; stdin, prompt, sops, random and tool ports permit isolated custody specs. `hostIdentity`
|
|
202
|
+
* (`{ label, read }`) is the harness CLI's secret.env SOPS_AGE_KEY, the last place before keys.txt the age identity is looked for.
|
|
203
|
+
*/
|
|
204
|
+
export async function secretMain(
|
|
137
205
|
argv,
|
|
138
206
|
{
|
|
139
207
|
stdout = (s) => process.stdout.write(s),
|
|
140
208
|
stderr = (s) => process.stderr.write(s),
|
|
141
|
-
stdin
|
|
209
|
+
stdin,
|
|
210
|
+
prompt,
|
|
211
|
+
hostIdentity = null,
|
|
142
212
|
sops = null,
|
|
143
213
|
random = randomBytes,
|
|
144
214
|
env = process.env,
|
|
145
215
|
cwd = invocationDir(),
|
|
146
216
|
platform = process.platform,
|
|
147
217
|
tools,
|
|
218
|
+
serverKeyIo = {},
|
|
148
219
|
} = {},
|
|
149
220
|
) {
|
|
150
221
|
const [verb, ...rest] = argv;
|
|
151
222
|
try {
|
|
152
223
|
if (!VERBS.has(verb))
|
|
153
224
|
throw new SecretError(
|
|
154
|
-
"usage: starci app secret init | set <env> <slug> <KEY> | get <env> <slug> [KEY] --to-file | unset <env> <slug> <KEY> | rename <env> <slug> <OLD> <NEW> | remove <env> <slug> | materialize <env> | list <env> | verify <env>",
|
|
225
|
+
"usage: starci app secret init | set <env> <slug> <KEY> [KEY ...] | get <env> <slug> [KEY] --to-file | unset <env> <slug> <KEY> | rename <env> <slug> <OLD> <NEW> | remove <env> <slug> | materialize <env> | list <env> | verify <env> | recipients <env> | migrate <env> <slug> [--to <slug>] [--into-existing] | split <env> <slug> [--group KEY=member] [--group-file <yaml>] [--into-existing] | audit [<env>] | server-key <env> [--rotate | --backup github | --restore github] [--execute]",
|
|
155
226
|
);
|
|
156
227
|
const opts = parse(rest);
|
|
157
228
|
optionsFor(verb, opts);
|
|
158
229
|
const repoRoot = path.resolve(cwd, opts.cwd ?? ".");
|
|
159
230
|
if (verb === "init")
|
|
160
231
|
return publicStorage(() =>
|
|
161
|
-
initializeSecrets(repoRoot, { env, platform, tools, stdout }),
|
|
232
|
+
initializeSecrets(repoRoot, { env, platform, tools, stdout, host: hostIdentity }),
|
|
233
|
+
);
|
|
234
|
+
if (verb === "audit") return printAudit(repoRoot, opts.positional[0] ?? null, stdout);
|
|
235
|
+
if (verb === "recipients")
|
|
236
|
+
return publicStorage(() =>
|
|
237
|
+
environmentRecipients(repoRoot, opts.positional[0], {
|
|
238
|
+
add: opts.add ?? [], remove: opts.remove ?? [], stdout,
|
|
239
|
+
owner: () => ownerRecipient(repoRoot, env, { platform, tools, host: hostIdentity }),
|
|
240
|
+
}),
|
|
162
241
|
);
|
|
163
242
|
const [environment, slug, key] = opts.positional;
|
|
243
|
+
if (verb === "server-key")
|
|
244
|
+
return await serverKeyVerb(repoRoot, environment, opts, {
|
|
245
|
+
env, stdout, stderr, serverKeyIo,
|
|
246
|
+
open: () => sops ?? defaultSops(env, repoRoot, platform, tools, hostIdentity),
|
|
247
|
+
});
|
|
164
248
|
const directory = secretsDirectory(repoRoot, environment);
|
|
165
249
|
if (verb === "list") return listSecrets(directory, stdout);
|
|
166
|
-
const open = () => sops ?? defaultSops(env, repoRoot, platform, tools);
|
|
250
|
+
const open = () => sops ?? defaultSops(env, repoRoot, platform, tools, hostIdentity);
|
|
167
251
|
if (EDIT_VERBS.has(verb))
|
|
168
252
|
return editSecret(verb, {
|
|
169
253
|
directory, environment, slug, names: opts.positional.slice(2),
|
|
170
254
|
execute: opts.execute === true, force: opts["force-nonempty"] === true, sops: open, stdout,
|
|
171
255
|
});
|
|
256
|
+
if (MOVE_VERBS.has(verb)) {
|
|
257
|
+
const move = { directory, environment, member: slug, to: opts.to, intoExisting: opts["into-existing"] === true, execute: opts.execute === true, sops: open, stdout };
|
|
258
|
+
if (verb === "migrate") return migrateMember(move);
|
|
259
|
+
return splitMember({ ...move, groups: groupMap(opts.group, opts["group-file"] === undefined ? null : path.resolve(cwd, opts["group-file"])) });
|
|
260
|
+
}
|
|
172
261
|
const seam = open();
|
|
173
262
|
if (verb === "verify")
|
|
174
263
|
return verifySecrets({ repoRoot, directory, environment, sops: seam, stdout });
|
|
@@ -177,9 +266,14 @@ export function secretMain(
|
|
|
177
266
|
return materializeSecrets(directory, seam, output);
|
|
178
267
|
const file = fileOf(directory, slug);
|
|
179
268
|
if (verb === "get") return getSecret(file, key, seam, opts, output, stderr);
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
269
|
+
const keys = opts.positional.slice(2);
|
|
270
|
+
const created = !fs.existsSync(file);
|
|
271
|
+
// Adding a key decrypts the member, so its identity is checked before any value is asked for; replacing every key does not.
|
|
272
|
+
if (!created && !envelopeOf(fs.readFileSync(file, "utf8")).keys.every((name) => keys.includes(name))) seam.requireIdentity?.(file);
|
|
273
|
+
const values = await setValues(keys, { generate: opts.generate, stdin, prompt, random, fromFile: fromFiles(opts["from-file"], keys) });
|
|
274
|
+
writeSecret({ file, values, age: [], sops: seam });
|
|
275
|
+
stdout(sealedLine(environment, slug, keys, file, created, seam));
|
|
276
|
+
stdout(declarationLine(environment, declareKeys(repoRoot, environment, slug, keys)));
|
|
183
277
|
return 0;
|
|
184
278
|
} catch (error) {
|
|
185
279
|
const message = secretErrorMessage(error);
|
|
@@ -127,6 +127,13 @@ function sideDeclaration(manifest, declaration, name, edition, providers) {
|
|
|
127
127
|
});
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** R334: an app's bare database function name must be a pg built-in the canon lists bare (ruleParams.be.dbFunctions); any other is schema.name. */
|
|
131
|
+
function bareDbFunctionProblems(manifest, be) {
|
|
132
|
+
const builtins = new Set((manifest.ruleParams?.be?.dbFunctions ?? []).map((entry) => entry.name).filter((name) => !name.includes('.')));
|
|
133
|
+
return (be?.dbFunctions ?? []).filter((entry) => !entry.name.includes('.') && !builtins.has(entry.name))
|
|
134
|
+
.map((entry) => `sides.be.dbFunctions names ${entry.name} bare, but a bare name is only a pg built-in of the canon's list (ruleParams.be.dbFunctions); name a function of a migration schema-qualified (public.${entry.name})`);
|
|
135
|
+
}
|
|
136
|
+
|
|
130
137
|
/**
|
|
131
138
|
* A declaration checked against the manifest: kind app, the pinned major the manifest's (HFS_MANIFEST_MAJOR_MISMATCH otherwise,
|
|
132
139
|
* and there is no compatibility window), and per side: app kinds of that profile, optionalSlots naming only opt-in slots of the
|
|
@@ -142,7 +149,7 @@ export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLA
|
|
|
142
149
|
if (declaration.kind === RUNTIME_KIND) return runtimeDeclaration(manifest, declaration, file, side);
|
|
143
150
|
const { edition, known, valid } = declarationEdition(manifest, declaration);
|
|
144
151
|
if (!valid) fail('HFS_EDITION_INVALID', `hfs.json edition is ${JSON.stringify(declaration.edition)}; the editions this manifest knows are ${known.join(', ')} (absent means full)`, { file, edition: declaration.edition });
|
|
145
|
-
const bad = PROFILES.flatMap((name) => sideProblems(manifest, name, declaration.sides[name], edition));
|
|
152
|
+
const bad = [...PROFILES.flatMap((name) => sideProblems(manifest, name, declaration.sides[name], edition)), ...bareDbFunctionProblems(manifest, declaration.sides.be)];
|
|
146
153
|
if (bad.length) declarationInvalid(bad, file);
|
|
147
154
|
if (side !== null && !PROFILES.includes(side)) fail('HFS_DECLARATION_INVALID', `${side} is not a side of an app (be, fe)`, { file, side });
|
|
148
155
|
// A provider declared on any connection enables the provider slots of every profile (a fe side declares none): each view carries the union.
|
|
@@ -13,6 +13,7 @@ export const KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
|
13
13
|
const VOLUME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
|
|
14
14
|
const HOST_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
15
15
|
const REMOTE_BASE = /^\/[A-Za-z0-9._/-]+$/u;
|
|
16
|
+
const AGE_RECIPIENT = /^age1[a-z0-9]+$/u;
|
|
16
17
|
const HTTP_URL = /^https?:\/\/[^\s]+$/u;
|
|
17
18
|
const PINNED_HELPER_IMAGE = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}$)[0-9a-f]{64}$/u;
|
|
18
19
|
const ARGUMENT = /^[A-Za-z0-9_@%+=:,./-]+$/u;
|
|
@@ -20,7 +21,7 @@ const UPPER_ENV = KEY;
|
|
|
20
21
|
|
|
21
22
|
const FIELDS = {
|
|
22
23
|
deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'ingress', 'registry'],
|
|
23
|
-
remote: ['host', 'releasesBase', 'sealed'],
|
|
24
|
+
remote: ['host', 'releasesBase', 'sealed', 'serverRecipient'],
|
|
24
25
|
sealed: ['ops', 'sshKey', 'knownHosts'],
|
|
25
26
|
image: ['app', 'buildArgs'],
|
|
26
27
|
buildArgs: ['values', 'sealed'],
|
|
@@ -51,9 +52,15 @@ function stringList(c, at, value, itemPattern, { nonEmpty = false } = {}) {
|
|
|
51
52
|
return value;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* `deploy.remote`: the ssh alias, the releases root, the sealed owner-machine session and `serverRecipient`, the public age
|
|
57
|
+
* recipient of the server's own identity that `starci app secret server-key` records.
|
|
58
|
+
*/
|
|
54
59
|
function remote(c, block) {
|
|
55
|
-
|
|
56
|
-
if (
|
|
60
|
+
const fallback = { host: 'vps', releasesBase: null, sealed: null, serverRecipient: null };
|
|
61
|
+
if (block === undefined) return fallback;
|
|
62
|
+
if (!closedFields(c, 'deploy.remote', block, FIELDS.remote)) return fallback;
|
|
63
|
+
if (block.serverRecipient !== undefined) patternField(c, 'deploy.remote.serverRecipient', block.serverRecipient, AGE_RECIPIENT);
|
|
57
64
|
if (block.host !== undefined) nameField(c, 'deploy.remote.host', block.host, HOST_ALIAS);
|
|
58
65
|
if (block.releasesBase !== undefined && (!REMOTE_BASE.test(String(block.releasesBase)) || String(block.releasesBase).includes('..')))
|
|
59
66
|
c.problem('deploy.remote.releasesBase', 'must be an absolute path without ..');
|
|
@@ -62,7 +69,7 @@ function remote(c, block) {
|
|
|
62
69
|
for (const key of FIELDS.sealed) nameField(c, `deploy.remote.sealed.${key}`, block.sealed[key]);
|
|
63
70
|
sealed = { ...block.sealed };
|
|
64
71
|
}
|
|
65
|
-
return { host: block.host ?? 'vps', releasesBase: block.releasesBase ?? null, sealed };
|
|
72
|
+
return { host: block.host ?? 'vps', releasesBase: block.releasesBase ?? null, sealed, serverRecipient: block.serverRecipient ?? null };
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
function imageBuildArgs(c, at, block, members) {
|
|
@@ -124,16 +131,30 @@ function grants(c, block, services, members) {
|
|
|
124
131
|
return out;
|
|
125
132
|
}
|
|
126
133
|
|
|
134
|
+
/**
|
|
135
|
+
* `deploy.secrets.settings`: one `{ member, keys }` or a list of them, one per member whose plain one-line values the stack
|
|
136
|
+
* interpolates; a key named by two entries is refused, so every setting has one source.
|
|
137
|
+
*/
|
|
127
138
|
function secretSettings(c, block, members) {
|
|
128
|
-
if (block === undefined) return
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return
|
|
139
|
+
if (block === undefined) return [];
|
|
140
|
+
const entries = Array.isArray(block) ? block : [block];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
return entries.flatMap((entry, index) => {
|
|
143
|
+
const at = Array.isArray(block) ? `deploy.secrets.settings[${index}]` : 'deploy.secrets.settings';
|
|
144
|
+
if (!closedFields(c, at, entry, FIELDS.settings)) return [];
|
|
145
|
+
memberRef(c, `${at}.member`, entry.member, members);
|
|
146
|
+
const keys = stringList(c, `${at}.keys`, entry.keys, KEY, { nonEmpty: true });
|
|
147
|
+
for (const key of keys) {
|
|
148
|
+
if (seen.has(key)) c.problem(`${at}.keys`, `${key} is named by another settings entry`);
|
|
149
|
+
seen.add(key);
|
|
150
|
+
}
|
|
151
|
+
return [{ member: entry.member, keys }];
|
|
152
|
+
});
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
function secrets(c, block, services, members) {
|
|
135
|
-
if (block === undefined) return { grants: [], settings:
|
|
136
|
-
if (!closedFields(c, 'deploy.secrets', block, FIELDS.secrets)) return { grants: [], settings:
|
|
156
|
+
if (block === undefined) return { grants: [], settings: [] };
|
|
157
|
+
if (!closedFields(c, 'deploy.secrets', block, FIELDS.secrets)) return { grants: [], settings: [] };
|
|
137
158
|
if (block.grants !== undefined && !Array.isArray(block.grants)) c.problem('deploy.secrets.grants', 'must be a list');
|
|
138
159
|
return { grants: grants(c, block.grants, services, members), settings: secretSettings(c, block.settings, members) };
|
|
139
160
|
}
|
|
@@ -6,6 +6,7 @@ import {parseYaml} from '../../engine/yaml.mjs';
|
|
|
6
6
|
import { validateAgainstSchema } from '../lib/json-schema.mjs';
|
|
7
7
|
import { PROVISION_OPERATION_SCHEMA_REF, provisionOperationSchema } from '../lib/provision-operation-schema.mjs';
|
|
8
8
|
import {slash} from '../lib/path-key.mjs';
|
|
9
|
+
import { envelopeOf } from './secret-document.mjs';
|
|
9
10
|
import { isInside, walkFiles } from '../lib/walk.mjs';
|
|
10
11
|
import { composeIncludePaths, swarmInventoryIssues, swarmModelIssues } from '../lib/swarm-stack.mjs';
|
|
11
12
|
import { swarmHardeningIssues } from './rules/stacks-swarm.mjs';
|
|
@@ -204,6 +205,7 @@ const checkStackEntry=(entry,environmentNames,add,{stacksRoot,lite})=>{
|
|
|
204
205
|
if(!entry.isDirectory()||entry.isSymbolicLink())return;
|
|
205
206
|
if(entry.name==='tmp'){add('STACKS_SCRATCH_IN_CANONICAL',`${STACKS_DIR}/tmp`,'scratch belongs in the OS temp directory, not a tracked .starcistacks/tmp');return;}
|
|
206
207
|
if(entry.name==='k8s')return;
|
|
208
|
+
if(lite&&entry.name==='images')return; // the managed db image (slot app.db.image, ruling lite-db-image-in-stacks), no environment
|
|
207
209
|
if(lite&&liteCustodyDirectory(path.join(stacksRoot,entry.name)))return;
|
|
208
210
|
if(!environmentNames.has(entry.name))add('STACKS_UNDECLARED_ENVIRONMENT',`${STACKS_DIR}/${entry.name}`,`${entry.name} is not an environment the declaration admits; either the declaration admits it or it is not an environment`);
|
|
209
211
|
};
|
|
@@ -254,9 +256,14 @@ const checkSecret=(secret,at,{envDir,environment,root,tracked,secretNames,add})=
|
|
|
254
256
|
if(!object(secret))return;
|
|
255
257
|
if(secretNames.has(secret.name))add('secret-duplicate',`${at}.name`,'secret names must be unique');else secretNames.add(secret.name);
|
|
256
258
|
const plain=nonempty(secret.where)?resolvedPath(envDir,secret.where):null;
|
|
257
|
-
|
|
259
|
+
// The member of `where` is `<where>.enc.yaml`, or a legacy `<where>.enc` (scripts/lib/sealed-member.mjs); an entry naming `key`
|
|
260
|
+
// declares that one key of the member, which its envelope must hold.
|
|
261
|
+
const member=nonempty(secret.where)?[`${secret.where}.enc.yaml`,`${secret.where}.enc`].map(name=>resolvedPath(envDir,name)).find(file=>file&®ular(file,{root:envDir})):null;
|
|
262
|
+
const enc=member??(nonempty(secret.where)?resolvedPath(envDir,`${secret.where}.enc.yaml`):null);
|
|
258
263
|
if(!enc||!regular(enc,{root:envDir})||!sopsEnvelope(enc))
|
|
259
|
-
add('encrypted-ref-invalid',`${at}.where`,'secret must resolve to <where>.enc as a recognizable SOPS envelope, an existing regular file below the environment directory');
|
|
264
|
+
add('encrypted-ref-invalid',`${at}.where`,'secret must resolve to <where>.enc.yaml (or a legacy <where>.enc) as a recognizable SOPS envelope, an existing regular file below the environment directory');
|
|
265
|
+
else if(nonempty(secret.key)&&!envelopeOf(fs.readFileSync(enc,'utf8')).keys.includes(secret.key))
|
|
266
|
+
add('encrypted-ref-invalid',`${at}.key`,`the member at ${secret.where} holds no ${secret.key}`);
|
|
260
267
|
if(nonempty(secret.where)&&!plain)
|
|
261
268
|
add('materialized-path-invalid',`${at}.where`,'secret plaintext path and its existing ancestors must remain below the environment directory without symlinks');
|
|
262
269
|
if(!String(secret.recipientPolicy??'').trim()||!String(secret.keyCustody??'').trim())
|
|
@@ -56,6 +56,6 @@ export function liteCustodyDirectory(directory) {
|
|
|
56
56
|
if (entry.isFile()) return entry.name === 'environment.json';
|
|
57
57
|
if (!entry.isDirectory() || entry.name !== 'secrets') return false;
|
|
58
58
|
return fs.readdirSync(path.join(directory, entry.name), { withFileTypes: true })
|
|
59
|
-
.every(secret => secret.isFile() && !secret.isSymbolicLink() && (secret.name === '.gitkeep' || /^[a-z0-9-]+\.enc
|
|
59
|
+
.every(secret => secret.isFile() && !secret.isSymbolicLink() && (secret.name === '.gitkeep' || /^[a-z0-9-]+\.enc(?:\.yaml)?$/.test(secret.name)));
|
|
60
60
|
});
|
|
61
61
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// sealed-member.mjs - the file names of an app's sealed members (ruling app-secrets-one-standard): a member is
|
|
2
|
+
// `.starcistacks/<env>/secrets/<member>.enc.yaml`, a sops YAML document of one provider's keys; a legacy member is
|
|
3
|
+
// `<member>.enc`, a sops dotenv document, read until `starci app secret migrate` moves it. Every reader of the secrets
|
|
4
|
+
// folder names, lists and resolves members through this module.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
/** The suffix of a member, and of a legacy one. */
|
|
9
|
+
export const MEMBER_SUFFIX = '.enc.yaml';
|
|
10
|
+
export const LEGACY_SUFFIX = '.enc';
|
|
11
|
+
|
|
12
|
+
/** A sealed member's file name (either suffix), as a pattern for a name or the tail of a path. */
|
|
13
|
+
export const SEALED_NAME = /\.enc(?:\.yaml)?$/u;
|
|
14
|
+
|
|
15
|
+
/** True for the file name of a sealed member, current or legacy. */
|
|
16
|
+
export const isSealedName = (name) => SEALED_NAME.test(name);
|
|
17
|
+
|
|
18
|
+
/** True for the file name of a legacy (dotenv `.enc`) member. */
|
|
19
|
+
export const isLegacyName = (name) => name.endsWith(LEGACY_SUFFIX);
|
|
20
|
+
|
|
21
|
+
/** The member a sealed file name holds (`sepay.enc.yaml` and `sepay.enc` are both `sepay`). */
|
|
22
|
+
export const memberOfName = (name) => name.replace(SEALED_NAME, '');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The file of member `slug` in `directory`: the existing `.enc.yaml`, else the existing legacy `.enc`, else the `.enc.yaml` a new
|
|
26
|
+
* member is created as. Both present is refused: one member has one file.
|
|
27
|
+
*/
|
|
28
|
+
export function memberFile(directory, slug) {
|
|
29
|
+
const current = path.join(directory, `${slug}${MEMBER_SUFFIX}`);
|
|
30
|
+
const legacy = path.join(directory, `${slug}${LEGACY_SUFFIX}`);
|
|
31
|
+
const hasCurrent = fs.existsSync(current);
|
|
32
|
+
if (hasCurrent && fs.existsSync(legacy))
|
|
33
|
+
throw new Error(`${slug} is sealed twice (${slug}${MEMBER_SUFFIX} and ${slug}${LEGACY_SUFFIX}); remove the one migrate replaced`);
|
|
34
|
+
if (hasCurrent) return current;
|
|
35
|
+
return fs.existsSync(legacy) ? legacy : current;
|
|
36
|
+
}
|
|
@@ -11,7 +11,7 @@ export const FORBIDDEN_FILES = [
|
|
|
11
11
|
{ name: 'private-key-file', test: (f) => /\.(pem|key|p12|pfx)$/i.test(f) || /(^|\/)id_(rsa|ed25519|ecdsa)$/i.test(f) },
|
|
12
12
|
// .starcistacks/<stack>/secrets/ commits sops twins only (owner ruling push-scan-test-secrets-encrypted): a plaintext
|
|
13
13
|
// credential there - a test password included - is git-ignored by the product repo and never pushed.
|
|
14
|
-
{ name: 'starcistacks-secret-plaintext', test: (f) => /(^|\/)\.starcistacks\/[^/]+\/secrets\//i.test(f) && !/(\.enc|\/\.gitkeep|\/KEYS\.md)$/i.test(f) },
|
|
14
|
+
{ name: 'starcistacks-secret-plaintext', test: (f) => /(^|\/)\.starcistacks\/[^/]+\/secrets\//i.test(f) && !/(\.enc|\.enc\.yaml|\/\.gitkeep|\/KEYS\.md)$/i.test(f) },
|
|
15
15
|
{ name: 'credentials-json', test: (f) => /(^|\/)(credentials|service-account|client_secret)[^/]*\.json$/i.test(f) },
|
|
16
16
|
];
|
|
17
17
|
// `word` in any case (name checks are case-aware by construction, not by flag).
|
|
@@ -51,7 +51,7 @@ export function swarmInventoryIssues({ components, lite = false }) {
|
|
|
51
51
|
for (const [id, entry] of Object.entries(catalog)) {
|
|
52
52
|
const component = mapping(entry);
|
|
53
53
|
if (lite && present(component.image) && BACKING_IMAGE.test(repositoryOf(component.image)))
|
|
54
|
-
add(`components.${id}.image`, 'lite runs no backing image; its db component runs the image db/Dockerfile builds, storage is the external S3 endpoint, identity the API');
|
|
54
|
+
add(`components.${id}.image`, 'lite runs no backing image; its db component runs the image .starcistacks/images/db/Dockerfile builds, storage is the external S3 endpoint, identity the API');
|
|
55
55
|
jobDeclarationIssues(id, component, catalog, add);
|
|
56
56
|
}
|
|
57
57
|
return errors;
|
|
@@ -119,7 +119,7 @@ export function swarmModelIssues({ model, components, bindings, lite = false })
|
|
|
119
119
|
else if (service.ports?.length && component.role !== 'ingress')
|
|
120
120
|
add(`services.${id}.ports`, 'only a component with role ingress may publish Swarm ports');
|
|
121
121
|
if (lite && present(service.image) && BACKING_IMAGE.test(repositoryOf(service.image)))
|
|
122
|
-
add(`services.${id}.image`, 'lite stack runs no backing image; its db service runs the image db/Dockerfile builds');
|
|
122
|
+
add(`services.${id}.image`, 'lite stack runs no backing image; its db service runs the image .starcistacks/images/db/Dockerfile builds');
|
|
123
123
|
jobServiceIssues(id, service, component, services, bound, add);
|
|
124
124
|
}
|
|
125
125
|
for (const id of Object.keys(bound)) {
|
|
@@ -102,7 +102,7 @@ const databasePatches = (root) => {
|
|
|
102
102
|
];
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
-
/** The first webhook installs only the lite inbox claim and
|
|
105
|
+
/** The first webhook installs only the lite inbox claim and the pgmq event bus, shared by all providers. */
|
|
106
106
|
const handoffPlatformFiles = (root, values) => {
|
|
107
107
|
const planned = [];
|
|
108
108
|
for (const capability of ["inbox", "idempotency", "event-bus"]) {
|
|
@@ -229,7 +229,7 @@ export function addLiteWebhookInbox({
|
|
|
229
229
|
...handoffPlatformFiles(root, renderValues),
|
|
230
230
|
];
|
|
231
231
|
const hasHandoffStorage = migrations.some((file) =>
|
|
232
|
-
/create\s+
|
|
232
|
+
/create\s+function\s+public\.event_bus_send\b/i.test(fs.readFileSync(path.join(migrationsDir, file), "utf8")),
|
|
233
233
|
);
|
|
234
234
|
if (!hasHandoffStorage)
|
|
235
235
|
planned[0].text = `${template("platform/migration.sql.tpl", renderValues)}\n${template("platform/client-operation-outcomes.sql.tpl", renderValues)}\n${planned[0].text}`;
|
package/scaffold/app.mjs
CHANGED
|
@@ -145,7 +145,7 @@ const STARTER_DEPENDENCIES = Object.freeze({
|
|
|
145
145
|
dependencies: {
|
|
146
146
|
'@apollo/server': null, '@nestjs/apollo': null, '@nestjs/common': null, '@nestjs/core': null, '@nestjs/cqrs': null, '@nestjs/graphql': null,
|
|
147
147
|
'@nestjs/platform-express': null, '@nestjs/typeorm': null, 'nest-commander': null, pg: null, graphql: null,
|
|
148
|
-
'class-transformer': null, 'class-validator': null, 'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null, zod: null,
|
|
148
|
+
'class-transformer': null, 'class-validator': null, 'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null, undici: null, zod: null,
|
|
149
149
|
},
|
|
150
150
|
devDependencies: {
|
|
151
151
|
turbo: null, '@nestjs/testing': null, '@starci/eslint-canon-be': null, '@starci/eslint-canon-fe': null, '@starci/jest-preset': null,
|
|
@@ -167,7 +167,7 @@ const LITE_STARTER_DEPENDENCIES = Object.freeze({
|
|
|
167
167
|
'@nestjs/platform-express': null, '@nestjs/typeorm': null, '@nestjs/passport': null, passport: null, 'passport-google-oauth20': null, 'passport-local': null,
|
|
168
168
|
'@aws-sdk/client-s3': null, '@aws-sdk/s3-request-presigner': null, bcryptjs: null,
|
|
169
169
|
'class-transformer': null, 'class-validator': null, graphql: null, jose: null, pg: null,
|
|
170
|
-
'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null, zod: null,
|
|
170
|
+
'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null, undici: null, zod: null,
|
|
171
171
|
},
|
|
172
172
|
devDependencies: {
|
|
173
173
|
turbo: null, '@nestjs/testing': null, '@starci/eslint-canon-be': null, '@starci/eslint-canon-fe': null,
|
package/src/main.mjs
CHANGED
|
@@ -108,7 +108,7 @@ starci app codemod button-intent [--write] [--format json|markdown] [--report <f
|
|
|
108
108
|
starci app sync (--check | --write) [--cwd <dir>]
|
|
109
109
|
starci app check [--cwd <dir>] [--json] [--fast] [--base <ref>] [--wiring] [--coverage-from <lcov>]
|
|
110
110
|
starci app db new <kebab-name> [--json] [--cwd <dir>]
|
|
111
|
-
starci app db baseline [--stamp <version>] [--url-env <NAME>] [--apply] [--cwd <dir>]
|
|
111
|
+
starci app db baseline [--stamp <version>] [--url-env <NAME>] [--squash [--drop-extension <a,b>] [--drop-pgmq-queue <q,job_*>] [--seed-tables <t1,t2>] [--image <ref>]] [--apply] [--cwd <dir>]
|
|
112
112
|
starci app db restamp --map <restamp.yaml> [--url-env <NAME>] [--apply] [--cwd <dir>]
|
|
113
113
|
starci app db plan [--url-env <NAME>] [--json] [--cwd <dir>]
|
|
114
114
|
starci app db dump [--url-env <NAME>] --out <dir> [--schema-only] [--json] [--cwd <dir>]
|
|
@@ -135,6 +135,7 @@ starci app secret remove <env> <slug> [--execute] [--force-nonempty] [--cwd <dir
|
|
|
135
135
|
starci app secret materialize <env> [--cwd <dir>]
|
|
136
136
|
starci app secret list <env> [--cwd <dir>]
|
|
137
137
|
starci app secret verify <env> [--cwd <dir>]
|
|
138
|
+
starci app secret server-key <env> [--rotate | --backup github | --restore github] [--execute] [--cwd <dir>]
|
|
138
139
|
starci app ci secret set <NAME> [--env E | --dependabot] [--repo owner/name] [--cwd <dir>]
|
|
139
140
|
starci app ci var set <NAME> [--env E] [--value v] [--repo owner/name] [--cwd <dir>]
|
|
140
141
|
starci app ci var sync [--execute] [--repo owner/name] [--cwd <dir>]
|
|
@@ -460,11 +461,12 @@ function errorMessage(error) {
|
|
|
460
461
|
/**
|
|
461
462
|
* `harness` ({ version }) is the CLI that runs this package in-process: its version is the starci-harness an app is judged and
|
|
462
463
|
* scaffolded at (runtime/scripts/hfs/canon-pins.mjs). `run` injects the database runner, `runLinter` the linter transport, `checkApp`
|
|
463
|
-
* the HFS check and `stackIo` Swarm tools; production defaults execute every check.
|
|
464
|
+
* the HFS check and `stackIo` Swarm tools; production defaults execute every check. `hostIdentity` ({ label, read }) is the
|
|
465
|
+
* harness CLI's own secret.env SOPS_AGE_KEY, which `secret` reads last for the age identity; `prompt` replaces its masked prompt.
|
|
464
466
|
*/
|
|
465
467
|
export async function main(argv, {
|
|
466
468
|
cwd: baseCwd = process.cwd(), harness, stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), presets, prettier, lock, run, stackMain,
|
|
467
|
-
env = process.env, stdin, sops, tools, platform, stackIo, runLinter, checkApp,
|
|
469
|
+
env = process.env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp,
|
|
468
470
|
} = {}) {
|
|
469
471
|
try {
|
|
470
472
|
const global = globalOptions(argv, path.resolve(baseCwd));
|
|
@@ -472,7 +474,7 @@ export async function main(argv, {
|
|
|
472
474
|
const out = global.quiet ? () => {} : stdout;
|
|
473
475
|
if (global.help) { out(USAGE); return 0; }
|
|
474
476
|
if (!VERBS.includes(verb)) { stderr(USAGE); return 2; }
|
|
475
|
-
return await dispatchMain(verb, rest, { cwd: global.cwd, harness, edition: global.edition, stdout: out, stderr, presets, prettier, lock, run, stackMain, env, stdin, sops, tools, platform, stackIo, runLinter, checkApp });
|
|
477
|
+
return await dispatchMain(verb, rest, { cwd: global.cwd, harness, edition: global.edition, stdout: out, stderr, presets, prettier, lock, run, stackMain, env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp });
|
|
476
478
|
} catch (error) {
|
|
477
479
|
stderr(errorMessage(error));
|
|
478
480
|
return 2;
|
package/stack/age-install.mjs
CHANGED
|
@@ -6,7 +6,8 @@ const AGE_VERSION = 'v1.2.1';
|
|
|
6
6
|
const AGE_URL = `https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-amd64.tar.gz`;
|
|
7
7
|
/** sha256 of the official linux/amd64 archive of AGE_VERSION. */
|
|
8
8
|
const AGE_SHA256 = '7df45a6cc87d4da11cc03a539a7470c15b1041ab2b396af088fe9990f7c79d50';
|
|
9
|
-
|
|
9
|
+
/** The install command of each package manager installAge detects; a refusal names them for a host it cannot install on. */
|
|
10
|
+
export const AGE_INSTALL = { dnf: 'sudo -n dnf install -y age', yum: 'sudo -n yum install -y age', apk: 'sudo -n apk add age', 'apt-get': 'sudo -n apt-get install -y age' };
|
|
10
11
|
|
|
11
12
|
const STATIC_INSTALL = [
|
|
12
13
|
'set -e',
|
|
@@ -26,8 +27,8 @@ export function installAge(run, report) {
|
|
|
26
27
|
if (present(run)) return;
|
|
27
28
|
const manager = packageManager(run);
|
|
28
29
|
let failure = 'no package manager (dnf, yum, apk, apt-get) found';
|
|
29
|
-
if (Object.hasOwn(
|
|
30
|
-
const packaged = run(
|
|
30
|
+
if (Object.hasOwn(AGE_INSTALL, manager)) {
|
|
31
|
+
const packaged = run(AGE_INSTALL[manager]);
|
|
31
32
|
if (packaged.status === 0 && present(run)) { report.note(`installed age with ${manager}`); return; }
|
|
32
33
|
failure = `${manager} could not install age: ${firstLine(packaged.stderr)}`;
|
|
33
34
|
}
|
package/stack/build.mjs
CHANGED
|
@@ -45,7 +45,7 @@ function ownerOf(ctx, config) {
|
|
|
45
45
|
return match[1].toLowerCase();
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** The Dockerfile of an app: be/apps/<app>/Dockerfile or fe/apps/<app>/Dockerfile, or db/Dockerfile for the db image; ambiguity and absence are refusals. */
|
|
48
|
+
/** The Dockerfile of an app: be/apps/<app>/Dockerfile or fe/apps/<app>/Dockerfile, or the managed .starcistacks/images/db/Dockerfile for the db image; ambiguity and absence are refusals. */
|
|
49
49
|
function dockerfileOf(config, app) {
|
|
50
50
|
if (app === DB_IMAGE_APP && fs.existsSync(path.join(config.repoRoot, DB_DOCKERFILE))) return DB_DOCKERFILE;
|
|
51
51
|
const found = SIDES.map((side) => `${side}/apps/${app}/Dockerfile`).filter((file) => fs.existsSync(path.join(config.repoRoot, file)));
|