@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.
Files changed (133) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +40 -1
  3. package/db/drift.mjs +19 -16
  4. package/db/image.mjs +13 -0
  5. package/db/migrate.mjs +3 -3
  6. package/db/postgres.mjs +1 -1
  7. package/package.json +2 -1
  8. package/runtime/knowledge/hfs/canon-pins.yaml +23 -8
  9. package/runtime/knowledge/hfs/rules.yaml +54 -13
  10. package/runtime/knowledge/hfs/slots.yaml +67 -36
  11. package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
  12. package/runtime/knowledge/patterns/be/idempotency.yaml +1 -1
  13. package/runtime/knowledge/patterns/be/jobs.yaml +3 -0
  14. package/runtime/knowledge/patterns/be/queues.yaml +14 -14
  15. package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
  16. package/runtime/knowledge/patterns/be/realtime.yaml +1 -1
  17. package/runtime/modules/kernel/failure-codes.yaml +28 -18
  18. package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
  19. package/runtime/scripts/gates/starcistacks.mjs +6 -2
  20. package/runtime/scripts/guards/push-scan.mjs +1 -1
  21. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  22. package/runtime/scripts/hfs/db-baseline-verb.mjs +94 -22
  23. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  24. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  25. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  26. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  27. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  28. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  29. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  30. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  31. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  32. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  33. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  34. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  35. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  36. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  38. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  39. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  40. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  41. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  42. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  43. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  44. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  45. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +3 -4
  46. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  47. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  48. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  49. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  50. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  51. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  52. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  53. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  54. package/runtime/scripts/hfs/secret-sops.mjs +93 -9
  55. package/runtime/scripts/hfs/secret-standard.mjs +315 -0
  56. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  57. package/runtime/scripts/hfs/secret.mjs +110 -46
  58. package/runtime/scripts/hfs/slots.mjs +8 -1
  59. package/runtime/scripts/hfs/stack-deploy-config.mjs +20 -6
  60. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  61. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  62. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  63. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  64. package/runtime/scripts/lib/swarm-stack.mjs +4 -6
  65. package/scaffold/add-inbox-lite.mjs +2 -2
  66. package/scaffold/add-queue-lite.mjs +24 -5
  67. package/scaffold/app.mjs +2 -2
  68. package/src/main.mjs +5 -4
  69. package/stack/build.mjs +3 -1
  70. package/stack/config.mjs +2 -1
  71. package/stack/docker-transport.mjs +2 -1
  72. package/stack/remote.mjs +4 -3
  73. package/stack/sealed.mjs +9 -8
  74. package/stack/target-binding.mjs +2 -1
  75. package/sync/hygiene.mjs +1 -1
  76. package/sync/index.mjs +47 -12
  77. package/templates/app/db-bootstrap/db/bootstrap.sql +9 -14
  78. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  79. package/templates/app/gitignore +2 -1
  80. package/templates/app/gitignore-lite +3 -1
  81. package/templates/app/package-scripts/db.json +2 -1
  82. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +4 -2
  83. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  84. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +1 -1
  85. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  86. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +1 -1
  87. package/templates/app/skeleton-lite/README.md +2 -2
  88. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  89. package/templates/be/inbox/events.json.tpl +1 -1
  90. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  91. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  92. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  93. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  94. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  95. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  96. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  97. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  98. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  99. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  100. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  101. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  102. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  103. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  104. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  105. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  106. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  107. package/templates/be/patterns/jobs/platform/jobs.log-events.ts.tpl +2 -0
  108. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  109. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  110. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +65 -0
  111. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +78 -0
  112. package/templates/be/patterns/jobs/platform-lite/job-ticks.contracts.ts.tpl +16 -0
  113. package/templates/be/patterns/jobs/platform-lite/jobs.module.ts.tpl +40 -0
  114. package/templates/be/patterns/jobs/platform-lite/jobs.sql.tpl +44 -0
  115. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  116. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  117. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  118. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  119. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  120. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  121. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  122. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +37 -2
  123. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -3
  124. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  125. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  126. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  127. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  128. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  129. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  130. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  131. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  132. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  133. 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 path.join(directory, `${slug}.enc`);
54
+ try { return memberFile(directory, slug); }
55
+ catch (error) { throw new SecretError(error.message); }
54
56
  };
55
57
 
56
- /** Build and validate a replacement envelope without publishing it. */
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
- map = sops.decrypt(file, envelope.format);
63
- if (!age.length) recipients = envelope.recipients;
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
- const sealed = sops.seal({ inputType: "dotenv", plaintext: plaintextOf("dotenv", map), recipients, filenameOverride: file });
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
- /** Update named keys through the same canonical sealed-secret owner used by the CLI. */
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 file = fileOf(directory, slug);
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((name) => name.endsWith(".enc"))
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 { validateKey } from "./secret-document.mjs";
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 (["--cwd", "--generate"].includes(flag)) {
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 remove");
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, 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") validateKey(opts.positional[2]);
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
- function secretValue(opts, stdin, random) {
114
- let value;
115
- if (opts.generate !== undefined) {
116
- const bytes = Number(opts.generate);
117
- if (!Number.isInteger(bytes) || bytes < 1 || bytes > 1024)
118
- throw new SecretError(
119
- "--generate must be a whole byte count from 1 to 1024",
120
- );
121
- value = random(bytes).toString("base64url");
122
- } else {
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
- if (!value) throw new SecretError("the value on stdin is empty");
132
- return value;
144
+ return files;
133
145
  }
134
146
 
135
- /** All app secrets verbs; stdin, sops, random and tool ports permit isolated custody specs. */
136
- export function secretMain(
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 = readStdin,
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 value = secretValue(opts, stdin, random);
181
- writeSecret({ file, values: { [key]: value }, age: [], sops: seam });
182
- stdout(`sealed ${environment}/${slug} (${key})\n`);
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 null;
129
- if (!closedFields(c, 'deploy.secrets.settings', block, FIELDS.settings)) return null;
130
- memberRef(c, 'deploy.secrets.settings.member', block.member, members);
131
- return { member: block.member, keys: stringList(c, 'deploy.secrets.settings.keys', block.keys, KEY, { nonEmpty: true }) };
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: null };
136
- if (!closedFields(c, 'deploy.secrets', block, FIELDS.secrets)) return { grants: [], settings: null };
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
- const enc=nonempty(secret.where)?resolvedPath(envDir,`${secret.where}.enc`):null;
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&&regular(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$/.test(secret.name)));
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)) && !isLiteDatabaseImage(component.image))
56
- add(`components.${id}.image`, 'lite runs no backing image but its db component (supabase/postgres); storage is the external S3 endpoint, identity the API');
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)) && !isLiteDatabaseImage(service.image))
124
- add(`services.${id}.image`, 'lite stack runs no backing image but its db service (supabase/postgres)');
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 transactional event publisher, shared by all providers. */
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+table\s+public\.event_bus\b/i.test(fs.readFileSync(path.join(migrationsDir, file), "utf8")),
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 at their canon pins, and the managed
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 missing = QUEUE_CLIENTS.filter((name) => typeof pkg.dependencies?.[name] !== 'string');
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,