@starci/hfs 4.11.12 → 4.11.15

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 (56) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +1 -1
  3. package/db/migrate.mjs +1 -1
  4. package/db/postgres.mjs +38 -3
  5. package/package.json +1 -1
  6. package/release/index.mjs +6 -5
  7. package/release/lint-baseline.mjs +123 -0
  8. package/release/urgent.mjs +19 -4
  9. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  10. package/runtime/knowledge/hfs/rules.yaml +3 -3
  11. package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
  12. package/runtime/scripts/api/sops/lib.mjs +25 -3
  13. package/runtime/scripts/api/sops/seal.mjs +4 -3
  14. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
  15. package/runtime/scripts/hfs/secret-init.mjs +15 -0
  16. package/runtime/scripts/hfs/secret-sops.mjs +65 -33
  17. package/runtime/scripts/lib/sops-envelope.mjs +36 -11
  18. package/scaffold/add-compose.mjs +3 -0
  19. package/scaffold/add-inbox-lite.mjs +45 -23
  20. package/scaffold/lite-exports.mjs +13 -0
  21. package/src/main.mjs +5 -5
  22. package/stack/age-install.mjs +10 -2
  23. package/stack/cli.mjs +5 -3
  24. package/stack/context.mjs +2 -2
  25. package/stack/deploy.mjs +6 -2
  26. package/stack/remote.mjs +29 -8
  27. package/stack/sealed.mjs +15 -11
  28. package/stack/server-key.mjs +31 -7
  29. package/stack/urgent-floor.mjs +101 -7
  30. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +1 -1
  31. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +1 -1
  32. package/templates/be/inbox/entities.index.ts.tpl +4 -0
  33. package/templates/be/inbox/entity.ts.tpl +29 -0
  34. package/templates/be/inbox/index.ts.tpl +1 -0
  35. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
  36. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
  37. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
  38. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
  39. package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
  40. package/templates/be/inbox/service.spec.ts.tpl +39 -15
  41. package/templates/be/inbox/service.ts.tpl +13 -13
  42. package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
  43. package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
  44. package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
  45. package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
  46. package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
  47. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
  48. package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -0
  49. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
  50. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +3 -3
  51. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
  52. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
  53. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
  54. package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
  55. package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
  56. package/templates/be/inbox/database-client.ts.tpl +0 -45
@@ -1,18 +1,25 @@
1
1
  // canon-copy-drift.mjs - HFS_CANON_COPY_DRIFT (R253): a handful of scaffolded files are hash-sensitive canon,
2
2
  // not files to edit. The drift judgement is one sha256 against the bytes of the be/skeleton template the file was
3
- // scaffolded from - the file equals canon, or it has drifted. A finding carries the path and both hashes, never
4
- // file content.
3
+ // scaffolded from - the file equals canon, or it has drifted. That template is the one of the @starci/hfs version the app
4
+ // pins (its lockfile's resolution of node_modules/@starci/hfs, else an exact package.json pin), never a newer hfs that
5
+ // happens to run the check: a skeleton change reaches an app only when the app raises its hfs. This package's own
6
+ // templates serve when it is that version or the app pins none; else an installed copy at the pinned version does; with
7
+ // neither at hand the copy is left to the app's own run of its pinned hfs. A finding carries the path and both hashes,
8
+ // never file content.
5
9
  import crypto from 'node:crypto';
6
10
  import fs from 'node:fs';
7
11
  import path from 'node:path';
8
12
  import { found } from './read.mjs';
9
13
 
10
14
  const CANON_COPY = 'HFS_CANON_COPY_DRIFT';
11
- const TEMPLATE_ROOTS = [
15
+ const OWN_ROOT = path.resolve(import.meta.dirname, '../../../..');
16
+ const OWN_TEMPLATE_ROOTS = [
12
17
  path.resolve(import.meta.dirname, '../../../packages/hfs/templates'),
13
- path.resolve(import.meta.dirname, '../../../../templates'),
18
+ path.join(OWN_ROOT, 'templates'),
14
19
  ];
15
20
  const TEMPLATE_DIR = 'be/skeleton/';
21
+ const HFS_LOCK_KEY = 'node_modules/@starci/hfs';
22
+ const EXACT = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
16
23
 
17
24
  // The three managed files the standard's secret-custody law pins byte-for-byte.
18
25
  const CANON_PATHS = [
@@ -23,8 +30,46 @@ const CANON_PATHS = [
23
30
  const REGEX_ESCAPE = String.raw`\$&`;
24
31
  const CANON_SUFFIX = new RegExp(`(?:^|/)(${CANON_PATHS.map((rel) => rel.replace(/[.*+?^${}()|[\]\\]/g, REGEX_ESCAPE)).join('|')})$`);
25
32
 
26
- const templateBytes = (rel) => {
27
- for (const root of TEMPLATE_ROOTS) {
33
+ const readJson = (file) => {
34
+ try {
35
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
36
+ } catch {
37
+ return undefined;
38
+ }
39
+ };
40
+ const versionAt = (folder) => readJson(path.join(folder, 'package.json'))?.version;
41
+
42
+ /** The @starci/hfs version the app at `repoRoot` pins: its lockfile's resolution (hoisted first), else an exact package.json pin. */
43
+ export function pinnedHfsVersion(repoRoot) {
44
+ const packages = readJson(path.join(repoRoot, 'package-lock.json'))?.packages ?? {};
45
+ const locked = Object.keys(packages)
46
+ .filter((key) => key === HFS_LOCK_KEY || key.endsWith(`/${HFS_LOCK_KEY}`))
47
+ .sort((left, right) => left.length - right.length)
48
+ .map((key) => packages[key]?.version)
49
+ .find((version) => typeof version === 'string');
50
+ if (locked) return locked;
51
+ const manifest = readJson(path.join(repoRoot, 'package.json')) ?? {};
52
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
53
+ const spec = manifest[field]?.['@starci/hfs'];
54
+ if (typeof spec === 'string' && EXACT.test(spec)) return spec;
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ /** The template roots of the app's pinned @starci/hfs, or null when no copy of that version is at hand. */
60
+ export function pinnedTemplateRoots(repoRoot) {
61
+ const pin = pinnedHfsVersion(repoRoot);
62
+ if (pin === undefined || versionAt(OWN_ROOT) === pin) return OWN_TEMPLATE_ROOTS;
63
+ const copies = [
64
+ path.join(repoRoot, 'node_modules', '@starci', 'hfs'),
65
+ path.join(repoRoot, 'node_modules', 'starci-harness', 'node_modules', '@starci', 'hfs'),
66
+ ];
67
+ const installed = copies.find((copy) => versionAt(copy) === pin);
68
+ return installed === undefined ? null : [path.join(installed, 'templates')];
69
+ }
70
+
71
+ const templateBytes = (roots, rel) => {
72
+ for (const root of roots) {
28
73
  const file = path.join(root, TEMPLATE_DIR, rel);
29
74
  try {
30
75
  const bytes = fs.readFileSync(file);
@@ -37,10 +82,12 @@ const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'
37
82
 
38
83
  export function canonCopyDriftFindings({ repoRoot, files, inScope }) {
39
84
  const findings = [];
85
+ const roots = pinnedTemplateRoots(repoRoot);
86
+ if (roots === null) return findings;
40
87
  for (const file of files.filter((f) => !inScope || inScope(f))) {
41
88
  const hit = CANON_SUFFIX.exec(file.replaceAll('\\', '/'));
42
89
  if (!hit) continue;
43
- const expected = templateBytes(hit[1]);
90
+ const expected = templateBytes(roots, hit[1]);
44
91
  if (!expected) continue;
45
92
  let actual;
46
93
  try {
@@ -106,6 +106,21 @@ export function localAgeIdentity(repoRoot, env, tools, host = null) {
106
106
  return { env: { ...env }, source: null, looked };
107
107
  }
108
108
 
109
+ /**
110
+ * The one age identity every decrypt of a sealed member opens with (secret get, verify, materialize, set over an existing member,
111
+ * migrate, split, the edit verbs, updatekeys, server-key, the stack's sealed members and the CLI's custody): localAgeIdentity's
112
+ * order (the environment, the app's `.claude/secret.env`, its main checkout's, the `host` port), then sops' per-user keys.txt.
113
+ * Same shape as localAgeIdentity; `looked` names every place read, keys.txt last.
114
+ */
115
+ export function decryptionIdentity(repoRoot, env, { tools, host = null, platform = process.platform } = {}) {
116
+ const local = localAgeIdentity(repoRoot, env, tools, host);
117
+ if (local.source !== null) return local;
118
+ const keys = secretRoots(env, platform).identity;
119
+ const looked = [...local.looked, keys];
120
+ if (!fs.existsSync(keys)) return { ...local, looked };
121
+ return { env: { ...local.env, SOPS_AGE_KEY_FILE: keys }, source: keys, looked };
122
+ }
123
+
109
124
  /** Load host-local age selections below the explicit environment (localAgeIdentity), without copying any other credentials. */
110
125
  export function ageIdentityEnv(repoRoot, env, tools) {
111
126
  return localAgeIdentity(repoRoot, env, tools).env;
@@ -1,5 +1,6 @@
1
1
  // secret-sops.mjs - runtime SOPS adapter and age identity selection.
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { runProgram } from "../api/process/run-program.mjs";
5
6
  import { resolveRealTool } from "../api/process/resolve-real-tool.mjs";
@@ -9,8 +10,8 @@ import { updateKeys as sopsUpdateKeys } from "../api/sops/update-keys.mjs";
9
10
  import { isLegacyName } from "../lib/sealed-member.mjs";
10
11
  import { invocationDir } from "../lib/roots.mjs";
11
12
  import { envelopeOf } from "./secret-document.mjs";
12
- import { configuredRecipients, localAgeIdentity, secretTools } from "./secret-init.mjs";
13
- import { outsideRepository, secretRoots } from "./secret-files.mjs";
13
+ import { configuredRecipients, decryptionIdentity, secretTools } from "./secret-init.mjs";
14
+ import { outsideRepository } from "./secret-files.mjs";
14
15
  import { SecretError, publicStorage } from "./secret-errors.mjs";
15
16
 
16
17
  const sopsInvocation = Object.freeze({ runProgram, resolveRealTool });
@@ -45,15 +46,27 @@ function recipientsOf(file) {
45
46
  * The refusal of a decrypt whose selected identity is not one `file` is sealed to: names where the identity came from and the
46
47
  * member's recipients, and the two ways out. It carries no value.
47
48
  */
48
- function identityMismatch(repoRoot, identity, file) {
49
+ function identityMismatch(repoRoot, identity, file, publicRecipient) {
49
50
  const recipients = recipientsOf(file);
51
+ const own = publicRecipient ? ` (recipient ${publicRecipient})` : "";
50
52
  return new SecretError(
51
- `the age identity of ${identity.source ?? "the environment"} does not open ${path.basename(file)}, ` +
52
- `sealed to ${recipients.join(", ") || "another recipient"}. Fix: put that recipient's age secret key in ${path.join(repoRoot, ".claude", "secret.env")} as SOPS_AGE_KEY, ` +
53
- "or, when no one holds it any more, seal the member's keys again (remove it and set them) after .sops.yaml names this identity's recipient",
53
+ `the age identity of ${identity.source ?? "the environment"}${own} does not open ${path.basename(file)}, ` +
54
+ `sealed to ${recipients.join(", ") || "another recipient"}. Fix: put one of those recipients' age secret key in ${path.join(repoRoot, ".claude", "secret.env")} as SOPS_AGE_KEY, ` +
55
+ "or, when no one holds any of them any more, seal the member's keys again (remove it and set them) after .sops.yaml names this identity's recipient",
54
56
  );
55
57
  }
56
58
 
59
+ /**
60
+ * Any other refusal of a decrypt: the member, the format it was read as, the recipients it is sealed to, where the age identity
61
+ * came from and (when derived) its recipient, then the refusal itself. Public facts only.
62
+ */
63
+ function identityRefused(identity, file, format, error) {
64
+ const recipients = recipientsOf(file);
65
+ const own = error.publicRecipient ? `, recipient ${error.publicRecipient}` : "";
66
+ const source = identity.source ? `the age identity of ${identity.source}${own}` : `no age identity (looked in ${identity.looked.join(", ") || "the environment"})`;
67
+ return `${path.basename(file)} (read as ${format ?? "its own format"}, sealed to ${recipients.join(", ") || "no age recipient"}) cannot be opened with ${source}: ${error.message}`;
68
+ }
69
+
57
70
  /**
58
71
  * The refusal of an operation that must decrypt `file` with no age identity selected: it names the recipient the member is
59
72
  * sealed to, every place looked, and the one line that fixes it. It carries no value.
@@ -70,8 +83,40 @@ function identityMissing(repoRoot, identity, file) {
70
83
  }
71
84
 
72
85
  /**
73
- * The default sops seam: the runtime's sops api, which finds the binary on this machine; the age identity is localAgeIdentity's
74
- * (`host` its last source), then the legacy sops keys.txt. `identity` says where the age identity
86
+ * The recipient set a seal of `file` writes: the caller's `named` set, else the environment's .sops.yaml rule, else the full set
87
+ * the existing member is sealed to, so no rewrite (set, migrate, split, an edit) drops a recipient such as the deploy server's.
88
+ * `ruled` says whether a rule of the app's .sops.yaml matches the file. Refused, as the rule refuses, when none names one.
89
+ */
90
+ function sealRecipients(repoRoot, file, named) {
91
+ try {
92
+ const rule = configuredRecipients(repoRoot, file);
93
+ return { recipients: named.length ? named : rule, ruled: true };
94
+ } catch (error) {
95
+ const existing = named.length ? named : fs.existsSync(file) ? recipientsOf(file) : [];
96
+ if (existing.length) return { recipients: existing, ruled: false };
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Runs `seal` with a private sops configuration naming only `recipients` when the app's .sops.yaml has no rule for the member
103
+ * (sops refuses a file no rule matches even with --age); the folder is removed after. Public keys only.
104
+ */
105
+ function withOwnRule(ruled, recipients, seal) {
106
+ if (ruled) return seal(null);
107
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "starci-sops-rule-"));
108
+ try {
109
+ const config = path.join(dir, ".sops.yaml");
110
+ fs.writeFileSync(config, `creation_rules:\n - age: ${recipients.join(",")}\n`);
111
+ return seal(config);
112
+ } finally {
113
+ fs.rmSync(dir, { recursive: true, force: true });
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The default sops seam: the runtime's sops api, which finds the binary on this machine; the age identity is decryptionIdentity's
119
+ * (`host` the harness CLI's secret.env, then sops' keys.txt). `identity` says where the age identity
75
120
  * was found (`source`, null when nowhere) and where it was looked for; `requireIdentity(file)` refuses, before any value is
76
121
  * read, an operation that must decrypt `file` with no identity.
77
122
  */
@@ -83,7 +128,7 @@ export function defaultSops(
83
128
  host = null,
84
129
  ) {
85
130
  const { sops: binary } = publicStorage(() => secretTools(env, tools));
86
- const local = localAgeIdentity(repoRoot, env, tools, host);
131
+ const local = decryptionIdentity(repoRoot, env, { tools, host, platform });
87
132
  const selectedEnv = local.env;
88
133
  const identity = { source: local.source, looked: [...local.looked] };
89
134
  const invocation = {
@@ -109,27 +154,16 @@ export function defaultSops(
109
154
  );
110
155
  checkIdentity(value);
111
156
  }
112
- if (
113
- !Object.keys(selectedEnv).some((name) => AGE_IDENTITY_NAME.test(name))
114
- ) {
115
- const keys = secretRoots(env, platform).identity;
116
- checkIdentity(keys);
117
- identity.looked.push(keys);
118
- if (fs.existsSync(keys)) {
119
- selectedEnv.SOPS_AGE_KEY_FILE = keys;
120
- identity.source = keys;
121
- }
122
- }
123
157
  const requireIdentity = (file) => {
124
158
  if (identity.source === null) throw identityMissing(repoRoot, identity, file);
125
159
  };
126
- const refuse = (what, result, file) => {
160
+ const refuse = (what, result, file, format) => {
127
161
  if (result.error?.identityRefusal === "identity-not-supplied" && file)
128
162
  return identityMissing(repoRoot, identity, file);
129
163
  if (result.error?.identityRefusal === "recipient-mismatch" && file)
130
- return identityMismatch(repoRoot, identity, file);
164
+ return identityMismatch(repoRoot, identity, file, result.error.publicRecipient);
131
165
  if (result.error?.identityRefusal)
132
- return Object.assign(new SecretError(result.error.message), {
166
+ return Object.assign(new SecretError(file ? identityRefused(identity, file, format, result.error) : result.error.message), {
133
167
  identityRefusal: result.error.identityRefusal,
134
168
  });
135
169
  if (result.error?.code === "SOPS_MISSING")
@@ -157,6 +191,7 @@ export function defaultSops(
157
191
  `decrypt ${path.basename(file)}; is the age identity installed?`,
158
192
  result,
159
193
  file,
194
+ format,
160
195
  );
161
196
  if (exact) return exactMap(result.stdout);
162
197
  const map = Object.create(null);
@@ -193,6 +228,7 @@ export function defaultSops(
193
228
  `decrypt ${path.basename(file)}; is the age identity installed?`,
194
229
  result,
195
230
  file,
231
+ types[0],
196
232
  );
197
233
  return String(result.stdout);
198
234
  },
@@ -207,21 +243,17 @@ export function defaultSops(
207
243
  { env: selectedEnv, cwd: repoRoot, invocation },
208
244
  );
209
245
  if (result.status !== 0)
210
- throw refuse(`update the keys of ${path.basename(file)}; is the age identity a recipient of it?`, result, file);
246
+ throw refuse(`update the keys of ${path.basename(file)}; is the age identity a recipient of it?`, result, file, isLegacyName(file) ? "dotenv" : "yaml");
211
247
  },
212
248
  seal(request) {
213
- const recipients = request.recipients.length
214
- ? request.recipients
215
- : publicStorage(() =>
216
- configuredRecipients(repoRoot, request.filenameOverride),
217
- );
218
- // Sealing needs only public recipients, so it runs with no identity selected and may name several (an environment's
249
+ const { recipients, ruled } = publicStorage(() => sealRecipients(repoRoot, request.filenameOverride, request.recipients));
250
+ // Sealing needs only public recipients, so it runs with no identity selected and seals to the whole set (an environment's
219
251
  // server key and the owner key both).
220
- const result = sopsSeal(
252
+ const result = withOwnRule(ruled, recipients, (config) => sopsSeal(
221
253
  binary,
222
- { ...request, recipients },
254
+ { ...request, recipients, config },
223
255
  { env: withoutIdentity(selectedEnv), cwd: repoRoot, invocation },
224
- );
256
+ ));
225
257
  if (result.status !== 0) throw refuse("seal the secret", result);
226
258
  return result.stdout;
227
259
  },
@@ -62,13 +62,17 @@ export function isSopsEnvelope(text) {
62
62
  try { return sopsTree(parseYaml(src)); } catch { return false; }
63
63
  }
64
64
 
65
- /** Admit one flat age envelope for the native selected-identity invocation; this is shape admission, never cryptographic proof. */
65
+ /** A refused admission, named by its reason. */
66
66
  const refusal = (reason) => ({ ok: false, reason });
67
67
 
68
+ const DOTENV_AGE_FIELD = /^age__list_(0|[1-9]\d{0,2})__map_(recipient|enc)$/u;
69
+
68
70
  function dotenvMetadataValue(meta, age, key, value) {
69
- if (key === 'age__list_0__map_recipient') age.recipient = value;
70
- else if (key === 'age__list_0__map_enc') age.enc = value;
71
- else if (key === 'mac_only_encrypted') {
71
+ const field = DOTENV_AGE_FIELD.exec(key);
72
+ if (field) {
73
+ const entry = (age[Number(field[1])] ??= {});
74
+ entry[field[2]] = value;
75
+ } else if (key === 'mac_only_encrypted') {
72
76
  if (value !== 'false') return 'partial-mac';
73
77
  meta[key] = false;
74
78
  } else if (key.includes('__')) return 'unsupported-key-group';
@@ -88,13 +92,14 @@ function readDotenvMetadata(text) {
88
92
  pairs.set(name, line.slice(at + 1).replaceAll(String.raw`\n`, '\n'));
89
93
  }
90
94
  const meta = {};
91
- const age = {};
95
+ const age = [];
92
96
  for (const [name, value] of pairs) {
93
97
  if (!name.startsWith('sops_')) continue;
94
98
  const reason = dotenvMetadataValue(meta, age, name.slice('sops_'.length), value);
95
99
  if (reason) return { refusal: reason };
96
100
  }
97
- meta.age = [age];
101
+ // A gap in the list indices is an entry sops never wrote: refused as an empty entry, never skipped.
102
+ meta.age = Array.from(age, (entry) => entry ?? null);
98
103
  return { meta };
99
104
  }
100
105
 
@@ -142,15 +147,35 @@ function validateAgeMetadata(meta, text, recipient) {
142
147
  const fields = new Set([...providers, 'age', 'lastmodified', 'mac', 'version', 'unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex', 'encrypted_regex', 'unencrypted_comment_regex', 'encrypted_comment_regex', 'mac_only_encrypted']);
143
148
  if (Object.keys(meta).some(name => !fields.has(name))) return refusal('unknown-metadata');
144
149
  if (Object.hasOwn(meta, 'mac_only_encrypted') && meta.mac_only_encrypted !== false) return refusal('partial-mac');
145
- if (!Array.isArray(meta.age) || meta.age.length !== 1) return refusal('unsupported-age-set');
146
- const entry = meta.age[0];
147
- if (!isAgeEntry(entry)) return refusal('invalid-age-entry');
148
- if (typeof entry.recipient !== 'string' || entry.recipient !== recipient) return refusal('recipient-mismatch');
149
- if (!hasValidAgePayload(entry)) return refusal('invalid-age-entry');
150
+ const set = ageSet(meta.age);
151
+ if (!set.ok) return set;
152
+ if (!set.recipients.includes(recipient)) return refusal('recipient-mismatch');
150
153
  if (!hasValidEnvelopeVersion(meta)) return refusal('invalid-envelope');
151
154
  return { ok: true };
152
155
  }
153
156
 
157
+ const AGE_RECIPIENT = /^age1[ac-hj-np-z02-9]+$/u;
158
+
159
+ /**
160
+ * The age set of an envelope: one or more entries (the owner key and an environment's server key are the standard pair), each a
161
+ * well-formed recipient with its wrapped data key; an empty entry or a recipient named twice is refused.
162
+ */
163
+ function ageSet(entries) {
164
+ if (!Array.isArray(entries) || entries.length === 0) return refusal('unsupported-age-set');
165
+ const recipients = [];
166
+ for (const entry of entries) {
167
+ if (!isAgeEntry(entry) || typeof entry.recipient !== 'string' || !AGE_RECIPIENT.test(entry.recipient) || !hasValidAgePayload(entry))
168
+ return refusal('invalid-age-entry');
169
+ if (recipients.includes(entry.recipient)) return refusal('duplicate-age-recipient');
170
+ recipients.push(entry.recipient);
171
+ }
172
+ return { ok: true, recipients };
173
+ }
174
+
175
+ /**
176
+ * Admit one flat age envelope for the native selected-identity invocation when `recipient` (the selected identity's public key) is
177
+ * one of its age set. Shape admission, never cryptographic proof.
178
+ */
154
179
  export function selectedAgeEnvelope(text, format, recipient) {
155
180
  if (!['binary', 'json', 'yaml', 'dotenv'].includes(format)) return refusal('unsupported-format');
156
181
  const parsed = readAgeMetadata(text, format);
@@ -7,7 +7,10 @@ import { ScaffoldError } from './service.mjs';
7
7
  export function addModuleImport(lines, { importLine, entry, relative, noun }) {
8
8
  const active = compositionCode(lines.join('\n')).split('\n');
9
9
  const imported = active.some(line => line.replace(/;\s*$/, '') === importLine);
10
+ // An area group spread once is registered whatever its argument (the lite root passes `tokens.verify` to domainModules).
11
+ const group = /^\.\.\.(\w+Modules)\(/.exec(entry)?.[1];
10
12
  const registered = active.some(line => line.trim() === `${entry},` ||
13
+ (group !== undefined && line.includes(`...${group}(`)) ||
11
14
  (/\bimports:\s*\[/.test(line) && !line.trim().startsWith('//') && line.replace(/\s+/g, '').includes(entry.replace(/\s+/g, ''))));
12
15
  const lastImport = active.reduce((last, line, index) => line.startsWith('import ') ? index : last, -1);
13
16
  const importsLine = line => /\bimports:\s*\[/.test(line) && !line.trim().startsWith('//');
@@ -13,6 +13,10 @@ const PLACEHOLDER = /@@([A-Za-z][A-Za-z0-9]*)@@/g;
13
13
  const timestampOf = (now) =>
14
14
  new Date(now()).toISOString().replace(/\D/g, "").slice(0, 14);
15
15
  const upperOf = (name) => name.replaceAll("-", "_").toUpperCase();
16
+ const camelOf = (name) => {
17
+ const pascal = pascalOf(name);
18
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
19
+ };
16
20
  const snakeOf = (name) => name.replaceAll("-", "_");
17
21
 
18
22
  const template = (relative, values) => {
@@ -72,8 +76,6 @@ const databasePatches = (root) => {
72
76
  "lite add webhook needs the scaffolded platform/database capability",
73
77
  );
74
78
  let index = fs.readFileSync(indexFile, "utf8").replace(/\r\n/g, "\n");
75
- if (!index.includes('export { recordWebhookDelivery, webhookReceiptInsert } from "./webhook-inbox.client"'))
76
- index = `${index.trimEnd()}\nexport { recordWebhookDelivery, webhookReceiptInsert } from "./webhook-inbox.client"\n`;
77
79
  if (!index.split("\n").some((line) => line.startsWith("export { ") && line.includes("DatabaseErrorCode") && line.endsWith(' } from "./errors/database.error"')))
78
80
  index = `${index.trimEnd()}\nexport { DatabaseError, DatabaseErrorCode } from "./errors/database.error"\n`;
79
81
  let error = withSqlIdentifierError(fs.readFileSync(errorFile, "utf8").replace(/\r\n/g, "\n"));
@@ -107,7 +109,15 @@ const handoffPlatformFiles = (root, values) => {
107
109
  const planned = [];
108
110
  for (const capability of ["inbox", "idempotency", "event-bus"]) {
109
111
  const base = `be/src/modules/platform/${capability}`;
110
- if (fs.existsSync(path.join(root, base, "index.ts"))) continue;
112
+ if (fs.existsSync(path.join(root, base, "index.ts"))) {
113
+ // The provider intake claims through the EntityInbox port; an inbox written before it cannot serve a new provider.
114
+ if (capability === "inbox" && !fs.readFileSync(path.join(root, base, "index.ts"), "utf8").includes("InjectEntityInbox"))
115
+ throw new ScaffoldError(
116
+ "HFS_ADD_INBOX_ENTITY_PORT",
117
+ `${base} predates the EntityInbox port (InjectEntityInbox, claimRow) the ${values.provider} intake claims through; bring it to the inbox of this hfs first`,
118
+ );
119
+ continue;
120
+ }
111
121
  const folder = path.join(TEMPLATES_DIR, "be", "inbox", "platform", capability);
112
122
  for (const file of filesBelow(folder)) {
113
123
  const relative = path.relative(folder, file).replaceAll("\\", "/");
@@ -149,6 +159,28 @@ const eventFiles = (root, provider, values) => {
149
159
  return { planned, patches };
150
160
  };
151
161
 
162
+ /** The lite entity list (BE-PERSISTENCE-3), where the provider inbox's `<provider>InboxEntities` is spread once. */
163
+ const PRIMARY_ENTITIES_FILE = ["be", "src", "modules", "platform", "database", "entities.ts"];
164
+ const PRIMARY_ENTITIES = /(export const PRIMARY_ENTITIES[^=]*=\s*\[)([\s\S]*?)(\])/;
165
+
166
+ /** entities.ts with the receipt entity list imported from the provider inbox barrel and spread into PRIMARY_ENTITIES. */
167
+ const primaryEntitiesPatch = (root, provider, list) => {
168
+ const file = path.join(root, ...PRIMARY_ENTITIES_FILE);
169
+ const relative = PRIMARY_ENTITIES_FILE.join("/");
170
+ if (!fs.existsSync(file))
171
+ throw new ScaffoldError("HFS_ADD_INBOX_DATABASE", `lite add webhook names ${list} in PRIMARY_ENTITIES of ${relative}, which is missing`);
172
+ const text = fs.readFileSync(file, "utf8").replace(/\r\n/g, "\n");
173
+ if (!PRIMARY_ENTITIES.test(text))
174
+ throw new ScaffoldError("HFS_ADD_INBOX_DATABASE", `lite add webhook names ${list} in PRIMARY_ENTITIES of ${relative}, which declares no PRIMARY_ENTITIES array`);
175
+ if (new RegExp(String.raw`\.\.\.${list}\b`).test(text)) return { file, text };
176
+ const withList = text.replace(PRIMARY_ENTITIES, (all, head, items, tail) =>
177
+ `${head}${[...items.split(",").map((item) => item.trim()).filter(Boolean), `...${list}`].join(", ")}${tail}`);
178
+ const lines = withList.split("\n");
179
+ const lastImport = lines.reduce((at, line, index) => (line.startsWith("import ") ? index : at), -1);
180
+ lines.splice(lastImport + 1, 0, `import { ${list} } from "@modules/domain/${provider}-inbox"`);
181
+ return { file, text: lines.join("\n") };
182
+ };
183
+
152
184
  const eventPatternPatch = (root) => {
153
185
  const file = path.join(root, "hfs.json");
154
186
  const declaration = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -190,6 +222,7 @@ export function addLiteWebhookInbox({
190
222
  ...values,
191
223
  provider,
192
224
  Provider,
225
+ providerCamel: camelOf(provider),
193
226
  providerUpper: upperOf(provider),
194
227
  table,
195
228
  };
@@ -221,6 +254,14 @@ export function addLiteWebhookInbox({
221
254
  relative: `${base}/${provider}-inbox.service.spec.ts`,
222
255
  text: template("service.spec.ts.tpl", renderValues),
223
256
  },
257
+ {
258
+ relative: `${base}/persistence/entities/${provider}-inbox.entity.ts`,
259
+ text: template("entity.ts.tpl", renderValues),
260
+ },
261
+ {
262
+ relative: `${base}/persistence/entities/index.ts`,
263
+ text: template("entities.index.ts.tpl", renderValues),
264
+ },
224
265
  {
225
266
  relative: `be/src/modules/events/${provider}/${provider}-delivery-recorded.event.ts`,
226
267
  text: template("delivery-recorded.event.ts.tpl", renderValues),
@@ -242,26 +283,6 @@ export function addLiteWebhookInbox({
242
283
  text: render(fs.readFileSync(path.join(TEMPLATES_DIR, "be", "skeleton-lite", "src", "modules", "platform", "database", file), "utf8"), {}),
243
284
  });
244
285
  }
245
- const databaseClient = path.join(
246
- root,
247
- "be",
248
- "src",
249
- "modules",
250
- "platform",
251
- "database",
252
- "webhook-inbox.client.ts",
253
- );
254
- if (!fs.existsSync(databaseClient))
255
- planned.push(
256
- {
257
- relative: "be/src/modules/platform/database/webhook-inbox.client.ts",
258
- text: template("database-client.ts.tpl", renderValues),
259
- },
260
- {
261
- relative: "be/src/modules/platform/database/webhook-inbox.client.spec.ts",
262
- text: template("database-client.spec.ts.tpl", renderValues),
263
- },
264
- );
265
286
  const clashes = planned
266
287
  .filter((entry) => fs.existsSync(path.join(root, ...entry.relative.split("/"))))
267
288
  .map((entry) => entry.relative);
@@ -272,6 +293,7 @@ export function addLiteWebhookInbox({
272
293
  );
273
294
  const patches = [
274
295
  ...databasePatches(root),
296
+ primaryEntitiesPatch(root, provider, `${renderValues.providerCamel}InboxEntities`),
275
297
  ...events.patches,
276
298
  eventPatternPatch(root),
277
299
  ];
@@ -108,6 +108,19 @@ const REGISTRATIONS = Object.freeze({
108
108
  source: "./webhook-signature.service",
109
109
  names: ["WebhookSignatureService"],
110
110
  },
111
+ {
112
+ // The webhook door types its request with the correlation the logging middleware stamps on it.
113
+ file: "be/src/modules/platform/logging/index.ts",
114
+ source: "./correlation.contracts",
115
+ names: ["CorrelationRequest"],
116
+ type: true,
117
+ },
118
+ {
119
+ // The lite event bus refuses an envelope whose correlation id is not one the logging capability issues.
120
+ file: "be/src/modules/platform/logging/index.ts",
121
+ source: "./correlation.policy",
122
+ names: ["isCorrelationId"],
123
+ },
111
124
  ],
112
125
  "webhook-full": [
113
126
  {
package/src/main.mjs CHANGED
@@ -116,11 +116,11 @@ starci app db migrate [--execute] [--url-env <NAME>] [--json] [--cwd <dir>]
116
116
  starci app db post-cutover [--execute] [--green-at <ISO> | --owner-confirms-old-app-gone] [--url-env <NAME>] [--json] [--cwd <dir>]
117
117
  starci app db drift [--url-env <NAME>] [--json] [--cwd <dir>]
118
118
  starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--backfill <tag>] [--json] [--cwd <dir>]
119
- starci app release push [--execute] [--remote <name>] [--urgent --reason <text>] [--json] [--cwd <dir>]
119
+ starci app release push [--execute] [--remote <name>] [--urgent --reason <text> [--lint-baseline <tag>]] [--json] [--cwd <dir>]
120
120
  starci app upgrade [--plan] [--cwd <dir>]
121
121
  starci app stack <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--cwd <dir>]
122
122
  starci app stack <prepare|doctor|connect|disconnect|build-args|render|deploy|converge|verify|rollback|prune|backup|jobs> --env <env> [--execute] [--json] [--cwd <dir>]
123
- starci app stack deploy --env <env> --source-sha <sha> --urgent [--db-url-env <NAME>] [--backup-out <dir>] [--execute] [--json] [--cwd <dir>]
123
+ starci app stack deploy --env <env> --source-sha <sha> --urgent [--db-url-env <NAME>] [--backup-out <dir>] [--first-start --db-service <service>] [--execute] [--json] [--cwd <dir>]
124
124
  starci app explain <path> [--cwd <dir>] [--json]
125
125
  starci app emit [--cwd <dir>]
126
126
  starci app new service <dir> <name> [--inject <binding>...] [--cwd <dir>]
@@ -435,8 +435,8 @@ function declaredEdition(cwd) {
435
435
  * `starci app stack`: the deploy actions (prepare, doctor, render, deploy, converge, verify, rollback, prune, backup, jobs, ...) are the Swarm
436
436
  * tooling of stack/cli.mjs; up, down and status are the full edition's test-world infrastructure.
437
437
  */
438
- async function stackAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr: err, env, sops, stackIo }) {
439
- if (STACK_ACTIONS.includes(argv[0])) return deployStackMain(argv, { ...stackIo, cwd, stdout: out, stderr: err, env, ...(sops ? { sops } : {}) });
438
+ async function stackAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr: err, env, sops, hostIdentity, stackIo }) {
439
+ if (STACK_ACTIONS.includes(argv[0])) return deployStackMain(argv, { ...stackIo, cwd, stdout: out, stderr: err, env, hostIdentity, ...(sops ? { sops } : {}) });
440
440
  if ((edition ?? declaredEdition(cwd)) === 'lite') throw new Error('starci app stack up|down|status manage the local test world of the full edition; a lite app starts its local database with npm run db:start');
441
441
  const run = stackMain ?? await installedStackMain(cwd);
442
442
  return run(argv, { cwd, out, err });
@@ -462,7 +462,7 @@ function errorMessage(error) {
462
462
  * `harness` ({ version }) is the CLI that runs this package in-process: its version is the starci-harness an app is judged and
463
463
  * scaffolded at (runtime/scripts/hfs/canon-pins.mjs). `run` injects the database runner, `runLinter` the linter transport, `checkApp`
464
464
  * the HFS check and `stackIo` Swarm tools; production defaults execute every check. `hostIdentity` ({ label, read }) is the
465
- * harness CLI's own secret.env SOPS_AGE_KEY, which `secret` reads last for the age identity; `prompt` replaces its masked prompt.
465
+ * harness CLI's own secret.env SOPS_AGE_KEY, which every verb that opens a sealed member (secret, stack, release, db) reads last for the age identity (decryptionIdentity); `prompt` replaces its masked prompt.
466
466
  */
467
467
  export async function main(argv, {
468
468
  cwd: baseCwd = process.cwd(), harness, stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), presets, prettier, lock, run, stackMain,
@@ -17,10 +17,18 @@ const STATIC_INSTALL = [
17
17
  `echo "${AGE_SHA256} $tmp/age.tgz" | sha256sum -c -`,
18
18
  'tar -xzf "$tmp/age.tgz" -C "$tmp"',
19
19
  'sudo -n install -m 0755 "$tmp/age/age" "$tmp/age/age-keygen" /usr/local/bin/',
20
+ // sudo's secure_path (AlmaLinux 10, RHEL) leaves /usr/local/bin out: a link in /usr/bin makes both tools visible to the deploy
21
+ // user and to root alike; an existing /usr/bin tool (a packaged age) is kept.
22
+ 'for b in age age-keygen; do if [ ! -e "/usr/bin/$b" ]; then sudo -n ln -s "/usr/local/bin/$b" "/usr/bin/$b"; fi; done',
20
23
  ].join('; ');
21
24
 
22
25
  const packageManager = (run) => run('for m in dnf yum apk apt-get; do command -v $m >/dev/null 2>&1 && echo $m && break; done').stdout.trim();
23
- const present = (run) => run('command -v age').status === 0;
26
+ /**
27
+ * Both tools, as the deploy user and, where passwordless sudo exists, as root: an install the root PATH cannot see is not taken
28
+ * for present.
29
+ */
30
+ const PRESENT = 'command -v age >/dev/null 2>&1 && command -v age-keygen >/dev/null 2>&1 && { [ "$(id -u)" -eq 0 ] || ! sudo -n true 2>/dev/null || sudo -n sh -c \'command -v age >/dev/null 2>&1 && command -v age-keygen >/dev/null 2>&1\'; }';
31
+ const present = (run) => run(PRESENT).status === 0;
24
32
 
25
33
  /** Installs age when it is missing; reports through `report`. */
26
34
  export function installAge(run, report) {
@@ -33,6 +41,6 @@ export function installAge(run, report) {
33
41
  failure = `${manager} could not install age: ${firstLine(packaged.stderr)}`;
34
42
  }
35
43
  const fetched = run(STATIC_INSTALL);
36
- if (fetched.status === 0 && present(run)) report.note(`installed the official static age ${AGE_VERSION} (sha256 verified) into /usr/local/bin; ${failure}`);
44
+ if (fetched.status === 0 && present(run)) report.note(`installed the official static age ${AGE_VERSION} (sha256 verified) into /usr/local/bin, linked from /usr/bin; ${failure}`);
37
45
  else report.bad(`cannot install age (${failure}); the static install failed too: ${firstLine(fetched.stderr)} (needs passwordless sudo once, or install age by hand)`);
38
46
  }
package/stack/cli.mjs CHANGED
@@ -14,8 +14,8 @@ const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollb
14
14
 
15
15
  export const ACTIONS = Object.freeze(['prepare', 'doctor', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept']);
16
16
 
17
- const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent']);
18
- const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out']);
17
+ const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent', '--first-start']);
18
+ const VALUE = new Set(['--env', '--image', '--source-sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out', '--db-service']);
19
19
  const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
20
20
 
21
21
  /** Splits argv into { flags, positional }; an unknown flag or a flag without its value is a refusal. */
@@ -72,6 +72,7 @@ const OPTIONS = {
72
72
  images: parseImages(flags.image), sourceSha: flags['source-sha'], execute: flags.execute === true,
73
73
  out: flags.out ? path.resolve(cwd, flags.out) : null, timeoutSeconds: number(flags, 'timeout'), retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true,
74
74
  urgent: flags.urgent === true, dbUrlEnv: flags['db-url-env'], backupOut: flags['backup-out'] ? path.resolve(cwd, flags['backup-out']) : undefined,
75
+ firstStart: flags['first-start'] === true, dbService: flags['db-service'],
75
76
  }),
76
77
  converge: ({ flags }) => ({ since: flags.since, timeoutSeconds: number(flags, 'timeout') }),
77
78
  verify: ({ flags }) => ({ retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true, skipInternal: flags['skip-internal'] === true }),
@@ -126,7 +127,8 @@ export async function stackMain(argv, io = {}) {
126
127
  if (binding && (!['deploy', 'verify'].includes(action) || flags['ssh-cmd'] || flags['skip-public'] || flags['skip-internal']))
127
128
  throw refusal('STACK_USAGE', 'the bound native consumer supports only complete deploy or verify');
128
129
  const config = bindStackTarget(ctx, loadDeployConfig({ repoRoot: ctx.cwd, env: flags.env ?? 'production' }), binding);
129
- if (action !== 'deploy' && (flags.urgent || flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--urgent, --db-url-env and --backup-out belong to deploy');
130
+ if (action !== 'deploy' && (flags.urgent || flags['db-url-env'] || flags['backup-out'] || flags['first-start'] || flags['db-service'])) throw refusal('STACK_USAGE', '--urgent, --db-url-env, --backup-out, --first-start and --db-service belong to deploy');
131
+ if (!flags.urgent && (flags['first-start'] || flags['db-service'])) throw refusal('STACK_USAGE', '--first-start and --db-service are the first start of an empty database under the urgent floor: add --urgent');
130
132
  if (action === 'deploy' && !flags.urgent && (flags['db-url-env'] || flags['backup-out'])) throw refusal('STACK_USAGE', '--db-url-env and --backup-out choose the urgent floor\'s database and backup folder: add --urgent');
131
133
  const options = OPTIONS[action]({ flags, positional, cwd: ctx.cwd });
132
134
  const handler = await handlerOf(action, io.importer ?? ((file) => import(file)));