@starci/hfs 4.10.1 → 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 +76 -0
- package/README.md +40 -1
- package/db/drift.mjs +19 -16
- package/db/image.mjs +13 -0
- package/db/migrate.mjs +3 -3
- package/db/postgres.mjs +1 -1
- package/package.json +2 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +23 -8
- package/runtime/knowledge/hfs/rules.yaml +54 -13
- package/runtime/knowledge/hfs/slots.yaml +67 -36
- package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
- package/runtime/knowledge/patterns/be/idempotency.yaml +1 -1
- package/runtime/knowledge/patterns/be/jobs.yaml +3 -0
- package/runtime/knowledge/patterns/be/queues.yaml +14 -14
- package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -1
- package/runtime/modules/kernel/failure-codes.yaml +28 -18
- 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 +94 -22
- 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 +3 -4
- 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 +4 -6
- package/scaffold/add-inbox-lite.mjs +2 -2
- package/scaffold/add-queue-lite.mjs +24 -5
- package/scaffold/app.mjs +2 -2
- package/src/main.mjs +5 -4
- package/stack/build.mjs +3 -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 +47 -12
- package/templates/app/db-bootstrap/db/bootstrap.sql +9 -14
- 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 +2 -1
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +4 -2
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +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/jobs.log-events.ts.tpl +2 -0
- 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 +65 -0
- package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +78 -0
- package/templates/be/patterns/jobs/platform-lite/job-ticks.contracts.ts.tpl +16 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.module.ts.tpl +40 -0
- package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +44 -0
- 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/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
|
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
import { claimFile } from "../api/fs/claim-file.mjs";
|
|
6
6
|
import { isSopsEnvelope } from "../lib/sops-envelope.mjs";
|
|
7
7
|
import { byCodeUnit } from "../lib/list.mjs";
|
|
8
|
+
import { LEGACY_SUFFIX, isLegacyName, isSealedName, memberFile } from "../lib/sealed-member.mjs";
|
|
8
9
|
import {
|
|
9
10
|
envelopeOf,
|
|
10
11
|
plaintextOf,
|
|
@@ -50,21 +51,30 @@ export const fileOf = (directory, slug) => {
|
|
|
50
51
|
throw new SecretError(
|
|
51
52
|
`${slug === undefined ? "a secret name" : slug} is not a secret slug: lowercase letters, digits and dashes`,
|
|
52
53
|
);
|
|
53
|
-
return
|
|
54
|
+
try { return memberFile(directory, slug); }
|
|
55
|
+
catch (error) { throw new SecretError(error.message); }
|
|
54
56
|
};
|
|
55
57
|
|
|
56
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Build and validate a replacement envelope without publishing it. An existing member is decrypted only when it holds a key
|
|
60
|
+
* `values` does not replace; a member whose every key is replaced (the one-key member a `set` re-seals) needs no age identity.
|
|
61
|
+
*/
|
|
57
62
|
function sealSecretMap({ file, values, age, sops }) {
|
|
58
63
|
let map = Object.create(null);
|
|
59
64
|
let recipients = age;
|
|
65
|
+
const legacy = isLegacyName(path.basename(file));
|
|
60
66
|
if (fs.existsSync(file)) {
|
|
61
67
|
const envelope = envelopeOf(fs.readFileSync(file, "utf8"));
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
if (!envelope.keys.every((key) => Object.hasOwn(values, key)))
|
|
69
|
+
map = sops.decrypt(file, envelope.format);
|
|
70
|
+
if (!age.length && legacy) recipients = envelope.recipients;
|
|
64
71
|
}
|
|
65
72
|
validateMap(map);
|
|
66
73
|
map = { ...map, ...values };
|
|
67
|
-
|
|
74
|
+
// A member is sops YAML, its values double-quoted scalars kept byte for byte, sealed to the recipients its environment's
|
|
75
|
+
// .sops.yaml rule names (so a re-seal follows a recipients change); a legacy member stays dotenv to its own recipients.
|
|
76
|
+
const format = legacy ? "dotenv" : "yaml";
|
|
77
|
+
const sealed = sops.seal({ inputType: format, plaintext: plaintextOf(format, map), recipients, filenameOverride: file });
|
|
68
78
|
let parsed;
|
|
69
79
|
try { parsed = envelopeOf(sealed); }
|
|
70
80
|
catch { throw new SecretError("sops returned an invalid sealed document"); }
|
|
@@ -112,14 +122,18 @@ export function writeSecret({ file, values, age, sops, write }) {
|
|
|
112
122
|
} finally { held.release(); }
|
|
113
123
|
}
|
|
114
124
|
|
|
115
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* Update named keys through the same canonical sealed-secret owner used by the CLI. `legacy` creates a new member as a dotenv
|
|
127
|
+
* `.enc`, for the sops-free bootstrap sealer of a fresh scaffold, which writes only that format; `migrate` moves it later.
|
|
128
|
+
*/
|
|
116
129
|
export function setSecretValues(
|
|
117
130
|
repoRoot,
|
|
118
131
|
{ slug, values, env = null },
|
|
119
|
-
{ sops = null, age = [], processEnv = process.env, write } = {},
|
|
132
|
+
{ sops = null, age = [], processEnv = process.env, write, legacy = false } = {},
|
|
120
133
|
) {
|
|
121
134
|
const directory = secretsDirectory(repoRoot, env);
|
|
122
|
-
const
|
|
135
|
+
const existing = fileOf(directory, slug);
|
|
136
|
+
const file = legacy && !fs.existsSync(existing) ? path.join(directory, `${slug}${LEGACY_SUFFIX}`) : existing;
|
|
123
137
|
if (
|
|
124
138
|
!values ||
|
|
125
139
|
typeof values !== "object" ||
|
|
@@ -144,7 +158,7 @@ export function sealedFiles(directory) {
|
|
|
144
158
|
return fs.existsSync(directory)
|
|
145
159
|
? fs
|
|
146
160
|
.readdirSync(directory)
|
|
147
|
-
.filter(
|
|
161
|
+
.filter(isSealedName)
|
|
148
162
|
.sort(byCodeUnit)
|
|
149
163
|
: [];
|
|
150
164
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
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).
|
|
@@ -7,19 +9,23 @@ import fs from "node:fs";
|
|
|
7
9
|
import path from "node:path";
|
|
8
10
|
import { randomBytes } from "node:crypto";
|
|
9
11
|
import { invocationDir } from "../lib/roots.mjs";
|
|
10
|
-
import { initializeSecrets } from "./secret-init.mjs";
|
|
11
|
-
import {
|
|
12
|
+
import { environmentRecipients, initializeSecrets, ownerRecipient } from "./secret-init.mjs";
|
|
13
|
+
import { declareKeys, groupMap, migrateMember, printAudit, splitMember } from "./secret-standard.mjs";
|
|
14
|
+
import { envelopeOf, validateKey } from "./secret-document.mjs";
|
|
12
15
|
import { defaultSops } from "./secret-sops.mjs";
|
|
13
16
|
import { fileOf, secretsDirectory, writeSecret } from "./secret-store.mjs";
|
|
14
17
|
import { getSecret, listSecrets, materializeSecrets } from "./secret-output.mjs";
|
|
15
18
|
import { SecretError, publicStorage, secretErrorMessage } from "./secret-errors.mjs";
|
|
16
19
|
import { editSecret, validateEditNames } from "./secret-edit.mjs";
|
|
17
20
|
import { verifySecrets } from "./rules/env-value-shape.mjs";
|
|
21
|
+
import { setValues } from "./secret-prompt.mjs";
|
|
18
22
|
|
|
19
23
|
export { envelopeOf, formatOf, plaintextOf } from "./secret-document.mjs";
|
|
20
24
|
export { setSecretValues } from "./secret-store.mjs";
|
|
21
25
|
|
|
22
|
-
const VERBS = new Set(["init", "list", "get", "set", "unset", "rename", "remove", "materialize", "verify"]);
|
|
26
|
+
const VERBS = new Set(["init", "list", "get", "set", "unset", "rename", "remove", "materialize", "verify", "recipients", "migrate", "split", "audit"]);
|
|
27
|
+
const MOVE_VERBS = new Set(["migrate", "split"]);
|
|
28
|
+
const LIST_FLAGS = ["--add", "--remove", "--from-file", "--group"];
|
|
23
29
|
const EDIT_VERBS = new Set(["unset", "rename", "remove"]);
|
|
24
30
|
|
|
25
31
|
function valueOption(opts, flag, value) {
|
|
@@ -35,9 +41,13 @@ function parse(argv) {
|
|
|
35
41
|
for (let i = 0; i < argv.length; i += 1) {
|
|
36
42
|
const arg = argv[i];
|
|
37
43
|
const [flag, inline] = arg.split(/=(.*)/su);
|
|
38
|
-
if (
|
|
44
|
+
if (LIST_FLAGS.includes(flag)) {
|
|
45
|
+
const value = inline ?? argv[++i];
|
|
46
|
+
if (value === undefined || value.startsWith("--")) throw new SecretError(`${flag} needs a value`);
|
|
47
|
+
(opts[flag.slice(2)] ??= []).push(value);
|
|
48
|
+
} else if (["--cwd", "--generate", "--to", "--group-file"].includes(flag)) {
|
|
39
49
|
valueOption(opts, flag, inline ?? argv[++i]);
|
|
40
|
-
} else if (["--to-file", "--reveal", "--execute", "--force-nonempty"].includes(flag)) {
|
|
50
|
+
} else if (["--to-file", "--reveal", "--execute", "--force-nonempty", "--into-existing"].includes(flag)) {
|
|
41
51
|
if (inline !== undefined && !["true", "false"].includes(inline))
|
|
42
52
|
throw new SecretError(`${flag} expects true or false`);
|
|
43
53
|
opts[flag.slice(2)] = inline !== "false";
|
|
@@ -48,27 +58,28 @@ function parse(argv) {
|
|
|
48
58
|
return opts;
|
|
49
59
|
}
|
|
50
60
|
|
|
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
61
|
function usageArguments(verb) {
|
|
60
62
|
if (verb === "init") return "";
|
|
61
|
-
if (verb === "set") return "<env> <slug> <KEY> [--generate <bytes>]";
|
|
63
|
+
if (verb === "set") return "<env> <slug> <KEY> [KEY ...] [--generate <bytes>]";
|
|
62
64
|
if (verb === "get") return "<env> <slug> [KEY] --to-file [--reveal]";
|
|
63
65
|
if (verb === "unset") return "<env> <slug> <KEY> [--execute]";
|
|
64
66
|
if (verb === "rename") return "<env> <slug> <OLD> <NEW> [--execute]";
|
|
65
67
|
if (verb === "remove") return "<env> <slug> [--execute] [--force-nonempty]";
|
|
68
|
+
if (verb === "recipients") return "<env> [--add <age1...|owner>] [--remove <age1...|owner>]";
|
|
69
|
+
if (verb === "migrate") return "<env> <slug> [--to <slug>] [--into-existing] [--execute]";
|
|
70
|
+
if (verb === "split") return "<env> <slug> [--group KEY=member ...] [--group-file <yaml>] [--into-existing] [--execute]";
|
|
71
|
+
if (verb === "audit") return "[<env>]";
|
|
66
72
|
return "<env>";
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
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
|
|
76
|
+
if (opts.execute !== undefined && !EDIT_VERBS.has(verb) && !MOVE_VERBS.has(verb))
|
|
77
|
+
throw new SecretError("--execute is only valid for unset, rename, remove, migrate and split");
|
|
78
|
+
if (opts.to !== undefined && verb !== "migrate") throw new SecretError("--to is only valid for migrate");
|
|
79
|
+
if ((opts.group || opts["group-file"] !== undefined) && verb !== "split") throw new SecretError("--group and --group-file are only valid for split");
|
|
80
|
+
if (opts["into-existing"] !== undefined && !MOVE_VERBS.has(verb)) throw new SecretError("--into-existing is only valid for migrate and split");
|
|
81
|
+
if ((opts.add || opts.remove) && verb !== "recipients") throw new SecretError("--add and --remove are only valid for recipients");
|
|
82
|
+
if (opts["from-file"] && verb !== "set") throw new SecretError("--from-file is only valid for set");
|
|
72
83
|
if (opts["force-nonempty"] !== undefined && verb !== "remove")
|
|
73
84
|
throw new SecretError("--force-nonempty is only valid for remove");
|
|
74
85
|
}
|
|
@@ -90,11 +101,15 @@ function optionsFor(verb, opts) {
|
|
|
90
101
|
list: [1, 1],
|
|
91
102
|
materialize: [1, 1],
|
|
92
103
|
verify: [1, 1],
|
|
93
|
-
set: [3,
|
|
104
|
+
set: [3, Infinity],
|
|
94
105
|
get: [2, 3],
|
|
95
106
|
unset: [3, 3],
|
|
96
107
|
rename: [4, 4],
|
|
97
108
|
remove: [2, 2],
|
|
109
|
+
recipients: [1, 1],
|
|
110
|
+
migrate: [2, 2],
|
|
111
|
+
split: [2, 2],
|
|
112
|
+
audit: [0, 1],
|
|
98
113
|
}[verb];
|
|
99
114
|
if (count < bounds[0] || count > bounds[1])
|
|
100
115
|
throw new SecretError(
|
|
@@ -105,40 +120,71 @@ function optionsFor(verb, opts) {
|
|
|
105
120
|
outputOptionsFor(verb, opts);
|
|
106
121
|
editOptionsFor(verb, opts);
|
|
107
122
|
if (EDIT_VERBS.has(verb)) validateEditNames(verb, opts.positional.slice(2));
|
|
108
|
-
if (verb === "set")
|
|
123
|
+
if (verb === "set") {
|
|
124
|
+
const keys = opts.positional.slice(2);
|
|
125
|
+
for (const key of keys) validateKey(key);
|
|
126
|
+
const twice = keys.find((key, index) => keys.indexOf(key) !== index);
|
|
127
|
+
if (twice) throw new SecretError(`set names ${twice} twice`);
|
|
128
|
+
}
|
|
109
129
|
if (verb === "get" && opts.positional[2] !== undefined)
|
|
110
130
|
validateKey(opts.positional[2]);
|
|
111
131
|
}
|
|
112
132
|
|
|
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, "");
|
|
133
|
+
/** The `--from-file KEY=path` values of set: each a named key, read byte for byte by setValues. */
|
|
134
|
+
function fromFiles(entries, keys) {
|
|
135
|
+
const files = {};
|
|
136
|
+
for (const entry of entries ?? []) {
|
|
137
|
+
const at = entry.indexOf("=");
|
|
138
|
+
const key = entry.slice(0, at);
|
|
139
|
+
if (at < 1 || !entry.slice(at + 1)) throw new SecretError("--from-file takes KEY=path");
|
|
140
|
+
if (!keys.includes(key)) throw new SecretError(`--from-file names ${key}, which this set does not`);
|
|
141
|
+
if (Object.hasOwn(files, key)) throw new SecretError(`--from-file names ${key} twice`);
|
|
142
|
+
files[key] = entry.slice(at + 1);
|
|
130
143
|
}
|
|
131
|
-
|
|
132
|
-
return value;
|
|
144
|
+
return files;
|
|
133
145
|
}
|
|
134
146
|
|
|
135
|
-
/**
|
|
136
|
-
|
|
147
|
+
/** What `set` declared in the environment's stack `secrets:`, or the keys to declare by hand; nothing when all were declared. */
|
|
148
|
+
function declarationLine(environment, { declared, byHand }) {
|
|
149
|
+
if (declared.length) return `declared ${declared.join(", ")} in environments.${environment}.secrets of application-stacks.yaml\n`;
|
|
150
|
+
if (byHand.length) return `declare ${byHand.join(", ")} in environments.${environment}.secrets of application-stacks.yaml (this environment's shape is wired by hand)\n`;
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The app root of a sealed member: `<root>/.starcistacks/<env>/secrets/<slug>.enc`. */
|
|
155
|
+
const appRootOf = (file) => path.resolve(path.dirname(file), "..", "..", "..");
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 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
|
|
159
|
+
* open it (the recipient it is sealed to, and where this machine holds that identity, or the fix when it holds none). Names
|
|
160
|
+
* and paths only.
|
|
161
|
+
*/
|
|
162
|
+
function sealedLine(environment, slug, keys, file, created, seam) {
|
|
163
|
+
const head = `sealed ${environment}/${slug} (${keys.join(", ")})`;
|
|
164
|
+
if (!created) return `${head}\n`;
|
|
165
|
+
let recipients = [];
|
|
166
|
+
try { recipients = envelopeOf(fs.readFileSync(file, "utf8")).recipients; }
|
|
167
|
+
catch { recipients = []; }
|
|
168
|
+
const of = recipients.length ? ` of ${recipients.join(", ")}` : "";
|
|
169
|
+
let held = "";
|
|
170
|
+
if (seam.identity?.source) held = `; this machine holds it in ${seam.identity.source}`;
|
|
171
|
+
else if (seam.identity)
|
|
172
|
+
held = `; this machine holds none: add SOPS_AGE_KEY to ${path.join(appRootOf(file), ".claude", "secret.env")} before the next set`;
|
|
173
|
+
return `${head}; a new member: adding keys to it later needs the age identity${of}${held}\n`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* All app secrets verbs; stdin, prompt, sops, random and tool ports permit isolated custody specs. `hostIdentity`
|
|
178
|
+
* (`{ label, read }`) is the harness CLI's secret.env SOPS_AGE_KEY, the last place before keys.txt the age identity is looked for.
|
|
179
|
+
*/
|
|
180
|
+
export async function secretMain(
|
|
137
181
|
argv,
|
|
138
182
|
{
|
|
139
183
|
stdout = (s) => process.stdout.write(s),
|
|
140
184
|
stderr = (s) => process.stderr.write(s),
|
|
141
|
-
stdin
|
|
185
|
+
stdin,
|
|
186
|
+
prompt,
|
|
187
|
+
hostIdentity = null,
|
|
142
188
|
sops = null,
|
|
143
189
|
random = randomBytes,
|
|
144
190
|
env = process.env,
|
|
@@ -151,24 +197,37 @@ export function secretMain(
|
|
|
151
197
|
try {
|
|
152
198
|
if (!VERBS.has(verb))
|
|
153
199
|
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>",
|
|
200
|
+
"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>]",
|
|
155
201
|
);
|
|
156
202
|
const opts = parse(rest);
|
|
157
203
|
optionsFor(verb, opts);
|
|
158
204
|
const repoRoot = path.resolve(cwd, opts.cwd ?? ".");
|
|
159
205
|
if (verb === "init")
|
|
160
206
|
return publicStorage(() =>
|
|
161
|
-
initializeSecrets(repoRoot, { env, platform, tools, stdout }),
|
|
207
|
+
initializeSecrets(repoRoot, { env, platform, tools, stdout, host: hostIdentity }),
|
|
208
|
+
);
|
|
209
|
+
if (verb === "audit") return printAudit(repoRoot, opts.positional[0] ?? null, stdout);
|
|
210
|
+
if (verb === "recipients")
|
|
211
|
+
return publicStorage(() =>
|
|
212
|
+
environmentRecipients(repoRoot, opts.positional[0], {
|
|
213
|
+
add: opts.add ?? [], remove: opts.remove ?? [], stdout,
|
|
214
|
+
owner: () => ownerRecipient(repoRoot, env, { platform, tools, host: hostIdentity }),
|
|
215
|
+
}),
|
|
162
216
|
);
|
|
163
217
|
const [environment, slug, key] = opts.positional;
|
|
164
218
|
const directory = secretsDirectory(repoRoot, environment);
|
|
165
219
|
if (verb === "list") return listSecrets(directory, stdout);
|
|
166
|
-
const open = () => sops ?? defaultSops(env, repoRoot, platform, tools);
|
|
220
|
+
const open = () => sops ?? defaultSops(env, repoRoot, platform, tools, hostIdentity);
|
|
167
221
|
if (EDIT_VERBS.has(verb))
|
|
168
222
|
return editSecret(verb, {
|
|
169
223
|
directory, environment, slug, names: opts.positional.slice(2),
|
|
170
224
|
execute: opts.execute === true, force: opts["force-nonempty"] === true, sops: open, stdout,
|
|
171
225
|
});
|
|
226
|
+
if (MOVE_VERBS.has(verb)) {
|
|
227
|
+
const move = { directory, environment, member: slug, to: opts.to, intoExisting: opts["into-existing"] === true, execute: opts.execute === true, sops: open, stdout };
|
|
228
|
+
if (verb === "migrate") return migrateMember(move);
|
|
229
|
+
return splitMember({ ...move, groups: groupMap(opts.group, opts["group-file"] === undefined ? null : path.resolve(cwd, opts["group-file"])) });
|
|
230
|
+
}
|
|
172
231
|
const seam = open();
|
|
173
232
|
if (verb === "verify")
|
|
174
233
|
return verifySecrets({ repoRoot, directory, environment, sops: seam, stdout });
|
|
@@ -177,9 +236,14 @@ export function secretMain(
|
|
|
177
236
|
return materializeSecrets(directory, seam, output);
|
|
178
237
|
const file = fileOf(directory, slug);
|
|
179
238
|
if (verb === "get") return getSecret(file, key, seam, opts, output, stderr);
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
239
|
+
const keys = opts.positional.slice(2);
|
|
240
|
+
const created = !fs.existsSync(file);
|
|
241
|
+
// Adding a key decrypts the member, so its identity is checked before any value is asked for; replacing every key does not.
|
|
242
|
+
if (!created && !envelopeOf(fs.readFileSync(file, "utf8")).keys.every((name) => keys.includes(name))) seam.requireIdentity?.(file);
|
|
243
|
+
const values = await setValues(keys, { generate: opts.generate, stdin, prompt, random, fromFile: fromFiles(opts["from-file"], keys) });
|
|
244
|
+
writeSecret({ file, values, age: [], sops: seam });
|
|
245
|
+
stdout(sealedLine(environment, slug, keys, file, created, seam));
|
|
246
|
+
stdout(declarationLine(environment, declareKeys(repoRoot, environment, slug, keys)));
|
|
183
247
|
return 0;
|
|
184
248
|
} catch (error) {
|
|
185
249
|
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.
|
|
@@ -124,16 +124,30 @@ function grants(c, block, services, members) {
|
|
|
124
124
|
return out;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* `deploy.secrets.settings`: one `{ member, keys }` or a list of them, one per member whose plain one-line values the stack
|
|
129
|
+
* interpolates; a key named by two entries is refused, so every setting has one source.
|
|
130
|
+
*/
|
|
127
131
|
function secretSettings(c, block, members) {
|
|
128
|
-
if (block === undefined) return
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return
|
|
132
|
+
if (block === undefined) return [];
|
|
133
|
+
const entries = Array.isArray(block) ? block : [block];
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
return entries.flatMap((entry, index) => {
|
|
136
|
+
const at = Array.isArray(block) ? `deploy.secrets.settings[${index}]` : 'deploy.secrets.settings';
|
|
137
|
+
if (!closedFields(c, at, entry, FIELDS.settings)) return [];
|
|
138
|
+
memberRef(c, `${at}.member`, entry.member, members);
|
|
139
|
+
const keys = stringList(c, `${at}.keys`, entry.keys, KEY, { nonEmpty: true });
|
|
140
|
+
for (const key of keys) {
|
|
141
|
+
if (seen.has(key)) c.problem(`${at}.keys`, `${key} is named by another settings entry`);
|
|
142
|
+
seen.add(key);
|
|
143
|
+
}
|
|
144
|
+
return [{ member: entry.member, keys }];
|
|
145
|
+
});
|
|
132
146
|
}
|
|
133
147
|
|
|
134
148
|
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:
|
|
149
|
+
if (block === undefined) return { grants: [], settings: [] };
|
|
150
|
+
if (!closedFields(c, 'deploy.secrets', block, FIELDS.secrets)) return { grants: [], settings: [] };
|
|
137
151
|
if (block.grants !== undefined && !Array.isArray(block.grants)) c.problem('deploy.secrets.grants', 'must be a list');
|
|
138
152
|
return { grants: grants(c, block.grants, services, members), settings: secretSettings(c, block.settings, members) };
|
|
139
153
|
}
|
|
@@ -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).
|
|
@@ -11,8 +11,6 @@ const BACKING_IMAGE_BROKERS_AND_OTHERS = '(?:cp-)?kafka(?:-native)?|keycloak|min
|
|
|
11
11
|
const BACKING_IMAGE = new RegExp(`^(?:${BACKING_IMAGE_SQL_AND_CACHE}|${BACKING_IMAGE_BROKERS_AND_OTHERS})$`, 'i');
|
|
12
12
|
const mapping = value => plain(value) ? value : {};
|
|
13
13
|
|
|
14
|
-
/** True for the one database image a lite app runs (its db component): supabase/postgres, pinned by digest. */
|
|
15
|
-
export const isLiteDatabaseImage = image => typeof image === 'string' && /^supabase\/postgres(?::[^@]+)?@sha256:[a-f0-9]{64}$/i.test(image);
|
|
16
14
|
const present = value => typeof value === 'string' && Boolean(value.trim());
|
|
17
15
|
const configured = value => Object.keys(mapping(value)).length > 0;
|
|
18
16
|
const schedulerImage = image => present(image) && /(?:^|\/)swarm-cronjob(?:[:@]|$)/.test(image);
|
|
@@ -52,8 +50,8 @@ export function swarmInventoryIssues({ components, lite = false }) {
|
|
|
52
50
|
const add = (at, message) => errors.push({ at, message });
|
|
53
51
|
for (const [id, entry] of Object.entries(catalog)) {
|
|
54
52
|
const component = mapping(entry);
|
|
55
|
-
if (lite && present(component.image) && BACKING_IMAGE.test(repositoryOf(component.image))
|
|
56
|
-
add(`components.${id}.image`, 'lite runs no backing image
|
|
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 .starcistacks/images/db/Dockerfile builds, storage is the external S3 endpoint, identity the API');
|
|
57
55
|
jobDeclarationIssues(id, component, catalog, add);
|
|
58
56
|
}
|
|
59
57
|
return errors;
|
|
@@ -120,8 +118,8 @@ export function swarmModelIssues({ model, components, bindings, lite = false })
|
|
|
120
118
|
add(`services.${id}.ports`, 'Swarm ports must be an array of published port definitions');
|
|
121
119
|
else if (service.ports?.length && component.role !== 'ingress')
|
|
122
120
|
add(`services.${id}.ports`, 'only a component with role ingress may publish Swarm ports');
|
|
123
|
-
if (lite && present(service.image) && BACKING_IMAGE.test(repositoryOf(service.image))
|
|
124
|
-
add(`services.${id}.image`, 'lite stack runs no backing image
|
|
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 .starcistacks/images/db/Dockerfile builds');
|
|
125
123
|
jobServiceIssues(id, service, component, services, bound, add);
|
|
126
124
|
}
|
|
127
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}`;
|
|
@@ -39,8 +39,23 @@ const LITE_TEMPLATES = Object.freeze({
|
|
|
39
39
|
'jobs/platform/jobs.index.ts.tpl': 'jobs/platform-lite/jobs.index.ts.tpl',
|
|
40
40
|
'jobs/platform/job-claim.service.ts.tpl': 'jobs/platform-lite/job-claim.service.ts.tpl',
|
|
41
41
|
'jobs/platform/job-claim.service.spec.ts.tpl': 'jobs/platform-lite/job-claim.service.spec.ts.tpl',
|
|
42
|
+
// The jobs module composes the one tick scheduler (R335, ruling:lite-official-postgres-image): ScheduleModule.forRoot() and JobTickService.
|
|
43
|
+
'jobs/platform/jobs.module.ts.tpl': 'jobs/platform-lite/jobs.module.ts.tpl',
|
|
42
44
|
});
|
|
43
45
|
|
|
46
|
+
/** The tick scheduler of platform/jobs the lite jobs capability adds beside full's tree: the ticks as data, the scheduler and its spec (R335). */
|
|
47
|
+
const TICK_FILES = Object.freeze([
|
|
48
|
+
['be/src/modules/platform/jobs/job-ticks.contracts.ts', 'jobs/platform-lite/job-ticks.contracts.ts.tpl'],
|
|
49
|
+
['be/src/modules/platform/jobs/job-tick.service.ts', 'jobs/platform-lite/job-tick.service.ts.tpl'],
|
|
50
|
+
['be/src/modules/platform/jobs/job-tick.service.spec.ts', 'jobs/platform-lite/job-tick.service.spec.ts.tpl'],
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** The tick files of a jobs capability the verb adds for the first time; an app that already has platform/jobs keeps its own. */
|
|
54
|
+
function tickFiles({ beRoot, patterns }) {
|
|
55
|
+
if (!patterns.includes('fenced-job') || fs.existsSync(path.join(beRoot, 'src', 'modules', 'platform', 'jobs', 'index.ts'))) return [];
|
|
56
|
+
return TICK_FILES.map(([relative, source]) => ({ relative, text: template(`be/patterns/${source}`) }));
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
/**
|
|
45
60
|
* The persistence files lite leaves out: the TypeORM schema (its authority is db/migrations) and the SQL text and row files (the
|
|
46
61
|
* statements are database functions of the lite migration, called through callDbFunction; R334 and the be.persistence lite overlay).
|
|
@@ -102,20 +117,24 @@ function migrationFiles({ repoRoot, beRoot, patterns, now }) {
|
|
|
102
117
|
|
|
103
118
|
/** The queue imports bullmq, which loads ioredis as an optional peer and cannot connect without it. */
|
|
104
119
|
const QUEUE_CLIENTS = Object.freeze(['bullmq', 'ioredis']);
|
|
120
|
+
/** The jobs capability's tick scheduler imports @nestjs/schedule and its CronJob (R335). */
|
|
121
|
+
const SCHEDULER_CLIENTS = Object.freeze(['@nestjs/schedule', 'cron']);
|
|
105
122
|
|
|
106
123
|
/**
|
|
107
|
-
* hfs.json declares redis in sides.be.backing; the package.json gains the queue's clients
|
|
124
|
+
* hfs.json declares redis in sides.be.backing; the package.json gains the queue's clients (and, for fenced-job, the tick
|
|
125
|
+
* scheduler's) at their canon pins, and the managed
|
|
108
126
|
* development scripts of the declared Redis that `starci app sync` renders for it. Each is added when it is missing, so a Redis an
|
|
109
127
|
* earlier capability declared (a cache on ioredis) still gains bullmq; a file already complete is left alone.
|
|
110
128
|
*/
|
|
111
|
-
function declarationPatches(repoRoot, project) {
|
|
129
|
+
function declarationPatches(repoRoot, project, patterns) {
|
|
112
130
|
const file = path.join(repoRoot, 'hfs.json');
|
|
113
131
|
const declaration = JSON.parse(read(file));
|
|
114
132
|
declaration.sides.be.backing = [...new Set([...(declaration.sides.be.backing ?? []), 'redis'])].sort(byCodeUnit);
|
|
115
133
|
const packageFile = path.join(repoRoot, 'package.json');
|
|
116
134
|
const pkg = JSON.parse(read(packageFile));
|
|
117
135
|
const pins = bundledPins();
|
|
118
|
-
const
|
|
136
|
+
const clients = [...QUEUE_CLIENTS, ...(patterns.includes('fenced-job') ? SCHEDULER_CLIENTS : [])];
|
|
137
|
+
const missing = clients.filter((name) => typeof pkg.dependencies?.[name] !== 'string');
|
|
119
138
|
for (const name of missing) if (!pins[name]) throw new ScaffoldError('HFS_SCAFFOLD_PIN_MISSING', `${name} has no canon pin in knowledge/hfs/canon-pins.yaml`);
|
|
120
139
|
if (missing.length)
|
|
121
140
|
pkg.dependencies = Object.fromEntries(Object.entries({ ...pkg.dependencies, ...Object.fromEntries(missing.map((name) => [name, pins[name].version])) }).sort(([a], [b]) => a.localeCompare(b)));
|
|
@@ -331,9 +350,9 @@ export function planLiteQueue({ repoRoot, repo, noun, patterns, now }) {
|
|
|
331
350
|
const beRoot = path.join(repoRoot, 'be');
|
|
332
351
|
const migrations = migrationFiles({ repoRoot, beRoot, patterns, now });
|
|
333
352
|
const database = skeletonFiles(repoRoot);
|
|
334
|
-
const declaration = declarationPatches(repoRoot, repo.project);
|
|
353
|
+
const declaration = declarationPatches(repoRoot, repo.project, patterns);
|
|
335
354
|
const stack = stackFiles(repoRoot, { project: repo.project, consumers: queueServicesOf(repo) });
|
|
336
|
-
const created = [...migrations, ...database.created, ...stack.created];
|
|
355
|
+
const created = [...migrations, ...tickFiles({ beRoot, patterns }), ...database.created, ...stack.created];
|
|
337
356
|
const patches = [...database.patches, ...declaration, ...stack.patches];
|
|
338
357
|
const clashes = created.filter((entry) => fs.existsSync(path.join(repoRoot, ...entry.relative.split('/')))).map((entry) => entry.relative);
|
|
339
358
|
if (clashes.length) throw new ScaffoldError('HFS_ADD_EXISTS', `add ${noun} would overwrite ${clashes.join(', ')}`);
|
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,
|