@starci/hfs 4.11.0 → 4.11.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +43 -1
  3. package/db/drift.mjs +8 -6
  4. package/db/image.mjs +9 -4
  5. package/db/migrate.mjs +3 -3
  6. package/package.json +2 -1
  7. package/runtime/knowledge/hfs/canon-pins.yaml +13 -8
  8. package/runtime/knowledge/hfs/rules.yaml +30 -13
  9. package/runtime/knowledge/hfs/slots.yaml +63 -37
  10. package/runtime/knowledge/patterns/be/event-bus.yaml +12 -10
  11. package/runtime/knowledge/patterns/be/queues.yaml +2 -3
  12. package/runtime/knowledge/patterns/be/reactors.yaml +1 -1
  13. package/runtime/modules/kernel/failure-codes.yaml +17 -17
  14. package/runtime/modules/schemas/application-stacks.schema.yaml +4 -2
  15. package/runtime/scripts/api/sops/lib.mjs +30 -0
  16. package/runtime/scripts/api/sops/update-keys.mjs +20 -0
  17. package/runtime/scripts/gates/starcistacks.mjs +6 -2
  18. package/runtime/scripts/guards/push-scan.mjs +1 -1
  19. package/runtime/scripts/hfs/ci-declaration.mjs +3 -2
  20. package/runtime/scripts/hfs/db-baseline-verb.mjs +50 -10
  21. package/runtime/scripts/hfs/db-errors.mjs +2 -0
  22. package/runtime/scripts/hfs/db-plan-verb.mjs +17 -5
  23. package/runtime/scripts/hfs/db-squash.mjs +514 -0
  24. package/runtime/scripts/hfs/declaration-shape.mjs +3 -15
  25. package/runtime/scripts/hfs/example-secrets.mjs +4 -3
  26. package/runtime/scripts/hfs/rule-params-shape.mjs +11 -9
  27. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -1
  28. package/runtime/scripts/hfs/rules/database-config.mjs +1 -1
  29. package/runtime/scripts/hfs/rules/database-constants.mjs +16 -1
  30. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +2 -110
  31. package/runtime/scripts/hfs/rules/database-queues.mjs +53 -239
  32. package/runtime/scripts/hfs/rules/database-sql.mjs +2 -0
  33. package/runtime/scripts/hfs/rules/database-transaction.mjs +45 -0
  34. package/runtime/scripts/hfs/rules/deploy-scan.mjs +1 -1
  35. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +19 -1
  36. package/runtime/scripts/hfs/rules/edition.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/env-parity.mjs +1 -1
  38. package/runtime/scripts/hfs/rules/lite-secrets.mjs +3 -3
  39. package/runtime/scripts/hfs/rules/script-hygiene.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +2 -2
  41. package/runtime/scripts/hfs/rules/secrets.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/stack-tooling.mjs +1 -1
  43. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +2 -1
  44. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +2 -2
  45. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +2 -2
  46. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +1 -1
  47. package/runtime/scripts/hfs/rules/stacks.mjs +3 -3
  48. package/runtime/scripts/hfs/sealed-members.mjs +10 -6
  49. package/runtime/scripts/hfs/secret-files.mjs +16 -0
  50. package/runtime/scripts/hfs/secret-init.mjs +120 -21
  51. package/runtime/scripts/hfs/secret-output.mjs +2 -1
  52. package/runtime/scripts/hfs/secret-prompt.mjs +128 -0
  53. package/runtime/scripts/hfs/secret-sops.mjs +108 -9
  54. package/runtime/scripts/hfs/secret-standard.mjs +369 -0
  55. package/runtime/scripts/hfs/secret-store.mjs +23 -9
  56. package/runtime/scripts/hfs/secret.mjs +140 -46
  57. package/runtime/scripts/hfs/slots.mjs +8 -1
  58. package/runtime/scripts/hfs/stack-deploy-config.mjs +31 -10
  59. package/runtime/scripts/hfs/stacks-gate.mjs +9 -2
  60. package/runtime/scripts/hfs/starcistacks-runtime.mjs +1 -1
  61. package/runtime/scripts/lib/sealed-member.mjs +36 -0
  62. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  63. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  64. package/scaffold/add-inbox-lite.mjs +2 -2
  65. package/scaffold/app.mjs +2 -2
  66. package/src/main.mjs +6 -4
  67. package/stack/age-install.mjs +4 -3
  68. package/stack/build.mjs +1 -1
  69. package/stack/config.mjs +2 -1
  70. package/stack/context.mjs +32 -2
  71. package/stack/docker-transport.mjs +2 -1
  72. package/stack/remote.mjs +8 -5
  73. package/stack/sealed.mjs +9 -8
  74. package/stack/server-key.mjs +296 -0
  75. package/stack/ssh-session.mjs +7 -1
  76. package/stack/target-binding.mjs +2 -1
  77. package/sync/hygiene.mjs +1 -1
  78. package/sync/index.mjs +36 -10
  79. package/templates/app/ci-workflows/github/workflows/images.yml +2 -0
  80. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +3 -1
  81. package/templates/app/ci-workflows/github/workflows/stack-key-restore.yml +58 -0
  82. package/templates/app/ci-workflows-lite/github/workflows/images.yml +2 -0
  83. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +3 -1
  84. package/templates/app/ci-workflows-lite/github/workflows/stack-key-restore.yml +58 -0
  85. package/templates/app/db-bootstrap/db/bootstrap.sql +7 -4
  86. package/templates/app/db-image/starcistacks/images/db/Dockerfile +18 -0
  87. package/templates/app/gitignore +2 -1
  88. package/templates/app/gitignore-lite +3 -1
  89. package/templates/app/package-scripts/db.json +1 -1
  90. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +1 -1
  91. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +1 -1
  92. package/templates/app/skeleton-lite/.starcistacks/production/README.md +1 -1
  93. package/templates/app/skeleton-lite/README.md +2 -2
  94. package/templates/be/inbox/delivery-recorded.event.ts.tpl +7 -5
  95. package/templates/be/inbox/events.json.tpl +1 -1
  96. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +58 -11
  97. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +13 -1
  98. package/templates/be/inbox/platform/event-bus/event-bus.log-events.ts.tpl +9 -0
  99. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +1 -1
  100. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +11 -5
  101. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +9 -2
  102. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +18 -5
  103. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +37 -16
  104. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +33 -5
  105. package/templates/be/inbox/platform/event-bus/event-consumer.service.spec.ts.tpl +143 -0
  106. package/templates/be/inbox/platform/event-bus/event-consumer.service.ts.tpl +119 -0
  107. package/templates/be/inbox/platform/event-bus/index.ts.tpl +13 -4
  108. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +4 -4
  109. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +4 -4
  110. package/templates/be/inbox/platform/migration.sql.tpl +97 -34
  111. package/templates/be/inbox/service.spec.ts.tpl +1 -1
  112. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  113. package/templates/be/patterns/jobs/platform-lite/job-claim.service.spec.ts.tpl +4 -4
  114. package/templates/be/patterns/jobs/platform-lite/job-claim.service.ts.tpl +5 -5
  115. package/templates/be/patterns/jobs/platform-lite/job-tick.service.spec.ts.tpl +1 -1
  116. package/templates/be/patterns/jobs/platform-lite/job-tick.service.ts.tpl +1 -1
  117. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.spec.ts.tpl +1 -1
  118. package/templates/be/patterns/queues/platform-lite/queue-outbox.service.ts.tpl +1 -1
  119. package/templates/be/patterns/queues/platform-lite/queue-relay.service.spec.ts.tpl +2 -2
  120. package/templates/be/patterns/queues/platform-lite/queue-relay.service.ts.tpl +2 -2
  121. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +4 -0
  122. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  123. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +1 -1
  124. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.spec.ts +38 -3
  125. package/templates/be/skeleton/src/modules/platform/http/fetch-http-client.client.ts +36 -4
  126. package/templates/be/skeleton/src/modules/platform/http/http.config.ts +11 -0
  127. package/templates/be/skeleton/src/modules/platform/http/http.decorators.ts +7 -0
  128. package/templates/be/skeleton/src/modules/platform/http/http.module-definition.ts +5 -3
  129. package/templates/be/skeleton/src/modules/platform/http/http.options.ts +9 -2
  130. package/templates/be/skeleton/src/modules/platform/http/index.ts +2 -0
  131. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +4 -0
  132. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +3 -0
  133. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +1 -1
  134. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +96 -0
  135. package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +93 -0
  136. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -1
  137. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +1 -0
  138. package/runtime/scripts/hfs/rules/database-queue-outbox.mjs +0 -102
  139. package/templates/app/db-image/db/Dockerfile +0 -8
  140. package/templates/be/skeleton-lite/src/modules/platform/database/database.functions.ts +0 -24
@@ -0,0 +1,45 @@
1
+ // R213 DB_MIGRATION_SHAPE, transaction control: `starci app db migrate` and the squash replay apply each migration in ONE transaction
2
+ // together with its history row (db/postgres.mjs psqlTransaction, `psql -1`), so a migration never opens, ends or splits a transaction.
3
+ // Top-level BEGIN, START TRANSACTION, COMMIT, END, ROLLBACK, ABORT, SAVEPOINT, RELEASE and PREPARE TRANSACTION are findings, and so is a
4
+ // COMMIT or ROLLBACK statement inside a DO block; a function or procedure body (dollar-quoted or not) is never judged.
5
+ import { found } from './read.mjs';
6
+ import { databaseSqlTokens } from './database-migrations.mjs';
7
+ import { DB_MIGRATION_SHAPE } from './database-constants.mjs';
8
+
9
+ const CONTROL_IN_DO = new Set(['commit', 'rollback']);
10
+ // PL/pgSQL words after which a statement starts.
11
+ const STATEMENT_OPENERS = new Set(['begin', 'then', 'else', 'loop']);
12
+
13
+ /** The line of the first token of the statement starting at byte `location` (leading blanks and comments skipped). */
14
+ function statementLine(text, location, lineAt) {
15
+ const rest = Buffer.from(text, 'utf8').subarray(Math.max(location, 0), Math.max(location, 0) + 4096).toString('utf8');
16
+ const first = databaseSqlTokens(rest)[0];
17
+ return lineAt(Math.max(location, 0)) + (first ? first.line - 1 : 0);
18
+ }
19
+
20
+ const doBody = (node) => (node.args ?? []).find((item) => item.DefElem?.defname === 'as')?.DefElem?.arg?.String?.sval ?? '';
21
+
22
+ /** Transaction control a migration must not carry, one finding per statement with its file:line. */
23
+ export function transactionControlFindings(file, text, stmts, lineAt) {
24
+ const findings = [];
25
+ for (const entry of stmts ?? []) {
26
+ const line = () => statementLine(text, entry.stmt_location ?? 0, lineAt);
27
+ if (entry.stmt?.TransactionStmt) {
28
+ const kind = String(entry.stmt.TransactionStmt.kind ?? '').replace(/^TRANS_STMT_/, '').toLowerCase().replaceAll('_', ' ');
29
+ const at = line();
30
+ findings.push(found(DB_MIGRATION_SHAPE, file, `${file}:${at} carries transaction control (${kind}); a migration is applied in one transaction with its history row, so it never begins, commits, rolls back or sets a savepoint`, { line: at }));
31
+ continue;
32
+ }
33
+ if (!entry.stmt?.DoStmt) continue;
34
+ const body = doBody(entry.stmt.DoStmt);
35
+ const tokens = databaseSqlTokens(body);
36
+ tokens.forEach((token, index) => {
37
+ const before = tokens[index - 1];
38
+ const leads = !before || before.v === ';' || (before.t === 'word' && STATEMENT_OPENERS.has(before.v));
39
+ if (token.t !== 'word' || !CONTROL_IN_DO.has(token.v) || !leads) return;
40
+ const inner = line() + token.line - 1;
41
+ findings.push(found(DB_MIGRATION_SHAPE, file, `${file}:${inner} carries transaction control (${token.v} inside a DO block); a migration is applied in one transaction with its history row`, { line: inner }));
42
+ });
43
+ }
44
+ return findings;
45
+ }
@@ -8,7 +8,7 @@ const DEPLOY = /(?:^|\/)deploy\//;
8
8
  const SCRIPT = /(?:^|\/)scripts\//;
9
9
  const COMPOSE = /(?:^|\/)(?:docker-)?compose(?:[.-][^/]*)?\.ya?ml$/;
10
10
  const DOCKERFILE = /(?:^|\/)Dockerfile(?:\.[^/]*)?$/;
11
- const CREDENTIAL_FILE_PARTS = [/(?:^|\/)secrets?\.env(?:\.[^/]*)?/, /(?:^|\/)\.env(?:\.[^/]*)?/, /(?:^|\/)secrets\//, /\.enc$/];
11
+ const CREDENTIAL_FILE_PARTS = [/(?:^|\/)secrets?\.env(?:\.[^/]*)?/, /(?:^|\/)\.env(?:\.[^/]*)?/, /(?:^|\/)secrets\//, /\.enc(?:\.yaml)?$/];
12
12
  const CREDENTIAL_FILE = new RegExp(CREDENTIAL_FILE_PARTS.map((part) => part.source).join('|'), 'i');
13
13
  const AT_JOB_ARGUMENT = /-[ft]\b|now\b|tomorrow\b|\d{1,2}:\d{2}\b|midnight\b|noon\b/.source;
14
14
  const HOST_CRON_PARTS = [
@@ -160,6 +160,24 @@ function deploymentJobFindings(name, job, doc, refuse) {
160
160
  return findings;
161
161
  }
162
162
 
163
+ const AGE_KEY_REFERENCE = /\$\{\{[^}]*\bsecrets\s*(?:\.\s*SOPS_AGE_KEY\b|\[\s*(['"])SOPS_AGE_KEY\1\s*\])/;
164
+ const readsAgeKey = (value) => strings(value).some((text) => AGE_KEY_REFERENCE.test(text));
165
+
166
+ /**
167
+ * A job that reads secrets.SOPS_AGE_KEY opens the members of one environment, so it is bound to that GitHub Environment, whose
168
+ * SOPS_AGE_KEY is the environment's server identity (PROJECT-STACKS-5/7); unbound, it would read a repository secret of that name.
169
+ * Disabled steps do not count. A deployment job is judged by deploymentJobFindings, which already requires its environment.
170
+ */
171
+ function ageKeyJobFindings(doc, file, refuse) {
172
+ const deployment = new Set(deploymentJobs(doc, file).map(([name]) => name));
173
+ const workflowWide = readsAgeKey(doc.env);
174
+ const jobs = record(doc.jobs) ? Object.entries(doc.jobs).filter(([, job]) => record(job)) : [];
175
+ return jobs
176
+ .filter(([name, job]) => !deployment.has(name) && !validEnvironment(job.environment))
177
+ .filter(([, job]) => workflowWide || readsAgeKey({ env: job.env, with: job.with, secrets: job.secrets, steps: activeSteps(job).map((step) => ({ env: step.env, with: step.with, run: nonempty(step.run) ? executable(step.run) : undefined })) }))
178
+ .map(([name]) => refuse(DEPLOY, `job ${name} reads secrets.SOPS_AGE_KEY without an environment; bind it to the GitHub Environment whose members it opens (environment: <env>), which holds that environment's server key`));
179
+ }
180
+
163
181
  function deploymentFindings(doc, file, refuse) {
164
182
  const deployment = deploymentJobs(doc, file);
165
183
  const named = deployName(file) || deployName(String(doc.name ?? ''));
@@ -216,7 +234,7 @@ function perWorkflow({ path: file, text }) {
216
234
  const refuse = (code, message) => found(code, file, `${code} ${file}: ${message}`);
217
235
  const doc = parseWorkflow(text);
218
236
  if (!record(doc)) return [refuse(TRIGGER, 'the workflow must be valid YAML with a release-only on block'), ...(deployName(file) ? [refuse(DEPLOY, 'the deploy workflow must be valid YAML')] : [])];
219
- return [...triggerFindings(doc, refuse, file), ...deploymentFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
237
+ return [...triggerFindings(doc, refuse, file), ...deploymentFindings(doc, file, refuse), ...ageKeyJobFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
220
238
  }
221
239
 
222
240
  /** Synchronous R251 findings over the tracked workflows of a lite/full app. */
@@ -95,7 +95,7 @@ function dependencyFindings(rel, pkg, { removed, catalog, declared }) {
95
95
  for (const name of Object.keys(deps)) {
96
96
  const service = serviceOfClient(catalog, name);
97
97
  if (LITE_FORBIDDEN_TEST_INFRA.test(name)) findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: a lite app runs no test containers, fault proxy or browser journey - its integration specs run against \`supabase start\``, { section, name }));
98
- else if (removed.has(name)) findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: lite runs no self-hosted Kafka - the event bus port is implemented on Supabase (pgmq) or in process`, { section, name }));
98
+ else if (removed.has(name)) findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: lite runs no self-hosted Kafka - the event bus port is implemented on pgmq in the db service`, { section, name }));
99
99
  else if (service !== null && !judgedInEdition(catalog[service], 'lite')) findings.push(found(HFS_BACKING_UNDECLARED, rel, `${rel} ${section} has ${name}, a client of ${service}, which the lite edition does not admit: remove the dependency`, { section, name, service }));
100
100
  else if (service !== null && !declared.has(service)) findings.push(found(HFS_BACKING_UNDECLARED, rel, `${rel} ${section} has ${name}, a client of ${service}, which hfs.json sides.be.backing does not declare: declare ${service} for the capability that needs it, or remove the dependency`, { section, name, service }));
101
101
  }
@@ -32,7 +32,7 @@ export const ENV_EXAMPLE_DRIFT = 'ENV_EXAMPLE_DRIFT';
32
32
  const HERE = path.dirname(fileURLToPath(import.meta.url));
33
33
  const ENV_SOURCE = /(?:\.env|\.config)\.ts$/u;
34
34
  const APP_MAIN = /(?:^|\/)apps\/[^/]+\/src\/main\.ts$/u;
35
- const SEALED_MEMBER = /^\.starcistacks\/[^/]+\/secrets\/[^/]+\.enc$/u;
35
+ const SEALED_MEMBER = /^\.starcistacks\/[^/]+\/secrets\/[^/]+\.enc(?:\.yaml)?$/u;
36
36
  const KEYS_ROSTER = /^\.starcistacks\/[^/]+\/runtime\/env\/KEYS\.md$/u;
37
37
  const STACK_YAML = /^\.starcistacks\/.+\.ya?ml$/u;
38
38
  const KEY_NAME = /^[A-Za-z_]\w*$/u;
@@ -11,7 +11,7 @@ const LOCKFILE = /(^|\/)package-lock\.json$/u;
11
11
  const ROOT_ENV = /^\.env(?:\..*)?$/iu;
12
12
  const SECRETS_ENV = /^secrets\.env$/iu;
13
13
  const SECRETS_DIRECTORY = /^\.secrets(?:\/|$)/iu;
14
- const SEALED_HOME = /^\.starcistacks\/[^/]+\/secrets\/[^/]+\.enc$/iu;
14
+ const SEALED_HOME = /^\.starcistacks\/[^/]+\/secrets\/[^/]+\.enc(?:\.yaml)?$/iu;
15
15
 
16
16
  const LITE_SECRET_PATTERNS = Object.freeze([
17
17
  { name: 'supabase-personal-access-token', re: /\bsbp_[A-Za-z0-9_-]{20,}\b/u },
@@ -52,10 +52,10 @@ function custodyPathFinding(file, repo, findings) {
52
52
  findings.push(found(LITE_SECRET_CUSTODY, file, `${file} is forbidden at the lite app root; lite keeps no .env*, secrets.env or .secrets/ custody path.`, { pattern: 'lite-forbidden-entry' }));
53
53
  return true;
54
54
  }
55
- if (file.endsWith('.enc') && !SEALED_HOME.test(file)) {
55
+ if (/\.enc(?:\.yaml)?$/iu.test(file) && !SEALED_HOME.test(file)) {
56
56
  findings.push(found(LITE_SECRET_CUSTODY, file, `${file} is a sealed file outside .starcistacks/<env>/secrets/; lite has exactly one encrypted custody path.`, { pattern: 'sealed-file-home' }));
57
57
  }
58
- } else if (file.endsWith('.enc')) {
58
+ } else if (/\.enc(?:\.yaml)?$/iu.test(file)) {
59
59
  findings.push(found(LITE_SECRET_CUSTODY, file, `${file} is a sealed file inside a side tree; lite keeps *.enc only at the app root under .starcistacks/<env>/secrets/.`, { pattern: 'sealed-file-home' }));
60
60
  }
61
61
  return false;
@@ -7,7 +7,7 @@ const PLAINTEXT_LOAD = 'HFS_SCRIPT_PLAINTEXT_LOAD';
7
7
  const XTRACE = 'HFS_SCRIPT_XTRACE';
8
8
  const SECRET_ENV_FILE = /(?:^|\/)(?:secrets?\.env|\.env)(?:\.[^/]*)?/iu;
9
9
  const SECRET_NIVO_FILE = /(?:^|\/)\.nivo-lite(?:\/|$)/iu;
10
- const SECRET_ENC_FILE = /\.enc$/iu;
10
+ const SECRET_ENC_FILE = /\.enc(?:\.yaml)?$/iu;
11
11
  const isSecretFile = (file) => SECRET_ENV_FILE.test(file) || SECRET_NIVO_FILE.test(file) || SECRET_ENC_FILE.test(file);
12
12
  const EXCLUDED_TREE = /(?:^|\/)(?:node_modules|\.git|\.claude|\.secrets|secrets|tests?|__tests__|docs|coverage|dist|build)(?:\/|$)/u;
13
13
  const TEST_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/u;
@@ -21,7 +21,7 @@ const ENV_FILE = /(?:^|\/)[^/.][^/]*\.env(?:\.(?!example|sample|template)[^/]*)?
21
21
  // which R06 already judges, and the sealed `.enc` members, which are custody.
22
22
  const SECRETS_DIR = /(?:^|\/)secrets\/[^/]+\.(?:ya?ml|env|json|conf|cfg|ini|toml|txt|properties)$/i;
23
23
  const SEALED_DIR = /^\.starcistacks\/[^/]+\/secrets\//;
24
- const SEALED_HOME = /^\.starcistacks\/[^/]+\/secrets\/[^/]+\.enc$/;
24
+ const SEALED_HOME = /^\.starcistacks\/[^/]+\/secrets\/[^/]+\.enc(?:\.yaml)?$/;
25
25
 
26
26
  const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_$][\w$]*)\s*=/;
27
27
  const hasSecretKeys = (file, text) =>
@@ -40,7 +40,7 @@ export function secretCustodyOnlyFindings({ repoRoot, files, repo, inScope }) {
40
40
  const findings = [];
41
41
  for (const file of files.filter((f) => !inScope || inScope(f))) {
42
42
  const posix = file.replaceAll('\\', '/');
43
- if (file.endsWith('.enc')) {
43
+ if (/\.enc(?:\.yaml)?$/.test(file)) {
44
44
  findings.push(...sealedCustodyFindings(file, posix, repo));
45
45
  continue;
46
46
  }
@@ -38,7 +38,7 @@ export function secretFileFindings({ file, text, manifestText }) {
38
38
  const being = FORBIDDEN_FILES.find((rule) => rule.test(file));
39
39
  if (being) return [found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret by being (${being.name}); a secret is committed only as a sops envelope at .starcistacks/<env>/secrets/<slug>.enc`, { pattern: being.name })];
40
40
  if (text === null || text === undefined || NOT_SCANNED.test(file)) return [];
41
- if (file.endsWith('.enc')) return isSopsEnvelope(text) ? [] : [found(PLAINTEXT_SECRET, file, `${file} is named like a sealed secret but is not a sops envelope; encrypt the value with sops and commit only the envelope`, { pattern: 'sops-not-envelope' })];
41
+ if (/\.enc(?:\.yaml)?$/.test(file)) return isSopsEnvelope(text) ? [] : [found(PLAINTEXT_SECRET, file, `${file} is named like a sealed secret but is not a sops envelope; encrypt the value with sops and commit only the envelope`, { pattern: 'sops-not-envelope' })];
42
42
  const findings = [];
43
43
  const seen = new Set();
44
44
  for (const { line, pattern } of secretHitsInFile(file, text, { manifestText })) {
@@ -15,7 +15,7 @@ const SOURCE_FILE = /(?:^|\/)[^/.]+$|\.(?:sh|bash|zsh|mjs|cjs|js|ts|mts|py|ya?ml
15
15
  const TEST_FILE = /\.(?:spec|test)\.[cm]?[jt]s$/;
16
16
  const CREDENTIAL_ENV_FILE = /(?:^|\/)(?:secrets?\.env|\.env)(?:\.[^/]*)?/i;
17
17
  const CREDENTIAL_SECRETS_DIR = /(?:^|\/)secrets\//i;
18
- const CREDENTIAL_ENC_FILE = /\.enc$/i;
18
+ const CREDENTIAL_ENC_FILE = /\.enc(?:\.yaml)?$/i;
19
19
  const isCredentialFile = (file) => CREDENTIAL_ENV_FILE.test(file) || CREDENTIAL_SECRETS_DIR.test(file) || CREDENTIAL_ENC_FILE.test(file);
20
20
  const VERB_FILE = /^(render|provision|converge|verify|prune|rollback|release|backup|prepare|doctor|jobs|public)\.(?:mjs|js|cjs|sh)$/;
21
21
  const COMMENT_LINE = /^[ \t]*(?:#|\/\/).*$/gm;
@@ -3,6 +3,7 @@
3
3
  // probe of the real tree: the files below .starcistacks/<env>/, the services of the first composeFiles entry and the sealed members.
4
4
  // One finding per problem; an environment without a deploy block is not a finding.
5
5
  import fs from 'node:fs';
6
+ import { isSealedName, memberOfName } from '../../lib/sealed-member.mjs';
6
7
  import path from 'node:path';
7
8
  import { isPlainObject as plain } from '../../../engine/plain-object.mjs';
8
9
  import { findStackDeclaration, readDeclaration, STACK_ROOT } from '../../lib/stack-declaration.mjs';
@@ -23,7 +24,7 @@ function stackServices(envDir, spec) {
23
24
 
24
25
  function sealedMembers(envDir) {
25
26
  try {
26
- return new Set(fs.readdirSync(path.join(envDir, 'secrets')).filter((name) => name.endsWith('.enc')).map((name) => name.slice(0, -4)));
27
+ return new Set(fs.readdirSync(path.join(envDir, 'secrets')).filter(isSealedName).map(memberOfName));
27
28
  } catch { return new Set(); }
28
29
  }
29
30
 
@@ -1,4 +1,4 @@
1
- // R276: lite runs exactly its own PostgreSQL (the db component, the image its managed db/Dockerfile builds) with pgvector, admits only the backing services available to its edition,
1
+ // R276: lite runs exactly its own PostgreSQL (the db component, the image its managed .starcistacks/images/db/Dockerfile builds) with pgvector, admits only the backing services available to its edition,
2
2
  // and runs a declared Redis as one service with AOF fsynced at least every second on a named volume and no eviction
3
3
  // (ruling:R212/case-10, owner:lite-vector-is-pgvector); sidecar SQLite persists on declared named volumes.
4
4
  import { isPlainObject as plain } from '../../../engine/plain-object.mjs';
@@ -45,7 +45,7 @@ function imageIssues(components, services, { catalog, declared }) {
45
45
  const service = serviceOfImage(catalog, entry.image);
46
46
  if (DATABASE_IMAGE.test(repositoryOf(entry.image)))
47
47
  errors.push({ code: 'STACKS_LITE_SELFHOSTED_DB', at: `${prefix}.${id}.image`,
48
- message: 'lite runs one database, the db component of the image db/Dockerfile builds (the official postgres image plus pgvector); no other Postgres or Mongo image' });
48
+ message: 'lite runs one database, the db component of the image .starcistacks/images/db/Dockerfile builds (the official postgres image plus pgvector); no other Postgres or Mongo image' });
49
49
  else if (service !== null && !judgedInEdition(catalog[service], 'lite'))
50
50
  errors.push({ code: 'STACKS_LITE_SELFHOSTED_DB', at: `${prefix}.${id}.image`,
51
51
  message: `lite must not run ${service}; vector search uses pgvector in the db service (owner:lite-vector-is-pgvector)` });
@@ -18,7 +18,7 @@ const issueFinding = (file, issue) => found(CODE, file, `${file}: ${issue.at}: $
18
18
  /** The app's tracked automation sources; locations are reported without returning any source text. */
19
19
  export function hostCrontabFindings({ repoRoot, files }) {
20
20
  const findings = [];
21
- for (const file of files.filter(file => HOST_CRON_PATH.test(file) && !file.endsWith('.enc'))) {
21
+ for (const file of files.filter(file => HOST_CRON_PATH.test(file) && !/\.enc(?:\.yaml)?$/.test(file))) {
22
22
  const text = readText(repoRoot, file);
23
23
  if (text === null) continue;
24
24
  for (const [index, line] of text.split(/\r?\n/).entries()) {
@@ -81,7 +81,7 @@ function loadSwarmModel({ repoRoot, environment, spec, files, findings }) {
81
81
  function swarmDeploymentFiles(repoRoot, files) {
82
82
  return files.filter(file => {
83
83
  const environment = /^\.starcistacks\/([^/]+)\/infra\/swarm\//.exec(file)?.[1];
84
- if (!environment || file.endsWith('.enc')) return false;
84
+ if (!environment || /\.enc(?:\.yaml)?$/.test(file)) return false;
85
85
  const target = safeStackFile(repoRoot, path.resolve(repoRoot, '.starcistacks', environment), file.split('/').slice(2).join('/'));
86
86
  if (!target) return true;
87
87
  const parsed = readDeclaration(target);
@@ -161,7 +161,7 @@ function safeRegularFile(repoRoot, file) {
161
161
  }
162
162
 
163
163
  function trackedConfigText(input, relative) {
164
- if (!present(relative) || relative.endsWith('.enc') || path.isAbsolute(relative)) return null;
164
+ if (!present(relative) || /\.enc(?:\.yaml)?$/.test(relative) || path.isAbsolute(relative)) return null;
165
165
  const directory = `.starcistacks/${input.environment}/`;
166
166
  const stack = input.spec.renderedModel ?? input.spec.composeFiles?.[0] ?? 'infra/swarm/stack.yaml';
167
167
  const file = path.posix.normalize(path.posix.join(directory, path.posix.dirname(stack), relative));
@@ -15,7 +15,7 @@ import { hostCrontabFindings, stacksRuntimeFindings } from './stacks-runtime.mjs
15
15
  const STACKS_SHAPE = 'HFS_STACKS_SHAPE';
16
16
  const STACKS_SLOT = 'app.starcistacks';
17
17
  const HOST_SONAR_ROOT = '.claude/ext/sonar';
18
- const INSIDE_SECRETS = /^[^/]+\/secrets\/[^/]+\.enc$/;
18
+ const INSIDE_SECRETS = /^[^/]+\/secrets\/[^/]+\.enc(?:\.yaml)?$/;
19
19
 
20
20
  /** The anchored expressions of a slot's `allows` entries: `<name>` is one path segment, braces alternate. */
21
21
  const allowedExpressions = (allows) => allows.flatMap((entry) => braceVariants(entry.replace(/<[a-z][a-z0-9-]*>/g, '*'))).map((variant) => globExpression(variant.endsWith('/') ? `${variant}**` : variant));
@@ -26,8 +26,8 @@ function treeFindings(files, allowed, prefix) {
26
26
  const findings = [];
27
27
  for (const file of files.filter((f) => f.startsWith(prefix))) {
28
28
  const rel = file.slice(prefix.length);
29
- if (rel.endsWith('.enc') && !INSIDE_SECRETS.test(rel)) findings.push(shapeFinding(file, `${file} is a sealed secret outside <env>/secrets/<slug>.enc; move it to .starcistacks/<env>/secrets/`));
30
- else if (!allowed.some((expression) => expression.test(rel))) findings.push(shapeFinding(file, `${file} is not part of the standard .starcistacks shape (application-stacks.yaml and <env>/{README.md, environment.json, infra, runtime/{config,env}, secrets/<slug>.enc, seeds}); move or delete it`));
29
+ if (/\.enc(?:\.yaml)?$/.test(rel) && !INSIDE_SECRETS.test(rel)) findings.push(shapeFinding(file, `${file} is a sealed secret outside <env>/secrets/<slug>.enc; move it to .starcistacks/<env>/secrets/`));
30
+ else if (!allowed.some((expression) => expression.test(rel))) findings.push(shapeFinding(file, `${file} is not part of the standard .starcistacks shape (application-stacks.yaml, images/db/Dockerfile in lite and <env>/{README.md, environment.json, infra, runtime/{config,env}, secrets/<slug>.enc, seeds}); move or delete it`));
31
31
  }
32
32
  return findings;
33
33
  }
@@ -5,10 +5,11 @@ import { randomUUID } from 'node:crypto';
5
5
  import { parseYaml } from '../../engine/yaml.mjs';
6
6
  import { byCodeUnit } from '../lib/list.mjs';
7
7
  import { setSecretValues } from './secret-store.mjs';
8
+ import { memberFile } from '../lib/sealed-member.mjs';
8
9
  import { sealBootstrapValues } from './secret-bootstrap-crypto.mjs';
9
10
 
10
11
  const ENVIRONMENT = /^[a-z0-9][a-z0-9-]*$/u;
11
- const MEMBER = /^secrets\/([a-z0-9][a-z0-9-]*)(?:\.enc)?$/u;
12
+ const MEMBER = /^secrets\/([a-z0-9][a-z0-9-]*)(?:\.enc(?:\.yaml)?)?$/u;
12
13
  const KEY = /^[A-Z][A-Z0-9_]*$/u;
13
14
 
14
15
  function readDocument(file, format) {
@@ -33,7 +34,7 @@ function rosterKeys(repoRoot, member) {
33
34
  for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/u)) {
34
35
  const key = /^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|/u.exec(line)?.[1];
35
36
  if (!key) continue;
36
- const locations = [...line.matchAll(/(?:secrets\/|\/run\/secrets\/)([a-z0-9][a-z0-9-]*)(?:\.enc)?/gu)];
37
+ const locations = [...line.matchAll(/(?:secrets\/|\/run\/secrets\/)([a-z0-9][a-z0-9-]*)(?:\.enc(?:\.yaml)?)?/gu)];
37
38
  if (locations.some((match) => match[1] === member.slug)) keys.push(key);
38
39
  }
39
40
  return keys;
@@ -84,16 +85,19 @@ function placeholderValue(key) {
84
85
  export function materializeDeclaredPlaceholders(repoRoot, { recipient, recipientsForFile, processEnv = process.env, sops, write, only = () => true } = {}) {
85
86
  const members = [];
86
87
  for (const member of declaredSealedMembers(repoRoot).filter((declared) => only(declared))) {
87
- const relative = `.starcistacks/${member.env}/secrets/${member.slug}.enc`;
88
- if (fs.existsSync(path.join(repoRoot, relative))) continue;
89
- const age = recipientsForFile?.(path.join(repoRoot, relative)) ?? [recipient];
88
+ const directory = path.join(repoRoot, '.starcistacks', member.env, 'secrets');
89
+ if (fs.existsSync(memberFile(directory, member.slug))) continue;
90
+ // The sops-free bootstrap sealer writes dotenv only, so its placeholder is a legacy `.enc` until `migrate` moves it.
91
+ const file = sops ? memberFile(directory, member.slug) : path.join(directory, `${member.slug}.enc`);
92
+ const relative = path.relative(repoRoot, file).split(path.sep).join('/');
93
+ const age = recipientsForFile?.(file) ?? [recipient];
90
94
  if (!age.length || age.some((value) => !/^age1[a-z0-9]+$/u.test(value ?? '')))
91
95
  throw new Error('sealed placeholders require the app public age recipient');
92
96
  if (!sops && age.length !== 1) throw new Error('multiple recipients require the standard SOPS adapter');
93
97
  const values = Object.fromEntries(member.keys.map((key) => [key, placeholderValue(key)]));
94
98
  const adapter = sops ?? { seal: () => sealBootstrapValues({ values, recipient: age[0] }) };
95
99
  setSecretValues(repoRoot, { slug: member.slug, values, env: member.env }, {
96
- age, processEnv, sops: adapter, write,
100
+ age, processEnv, sops: adapter, write, legacy: !sops,
97
101
  });
98
102
  members.push(relative);
99
103
  }
@@ -122,3 +122,19 @@ export function writeSecretFile(directory, key, value) {
122
122
  }
123
123
  return file;
124
124
  }
125
+
126
+ /**
127
+ * The main checkout of a git worktree (its `.git` file names `<main>/.git/worktrees/<name>`), else `root` itself: a worktree
128
+ * keeps no git-ignored `.claude/secret.env` of its own, so the app's age identity is read from the checkout it was cut from.
129
+ */
130
+ export function mainCheckout(root) {
131
+ const dotGit = path.join(root, ".git");
132
+ const stat = fs.lstatSync(dotGit, { throwIfNoEntry: false });
133
+ if (!stat?.isFile()) return root;
134
+ const gitdir = /^gitdir:\s*(.+)$/mu
135
+ .exec(fs.readFileSync(dotGit, "utf8"))?.[1]
136
+ ?.trim()
137
+ .replaceAll("\\", "/");
138
+ const main = gitdir && /^(.*)\/\.git\/worktrees\/[^/]+$/u.exec(gitdir)?.[1];
139
+ return main && fs.existsSync(main) ? path.resolve(main) : root;
140
+ }
@@ -12,12 +12,18 @@ import { generateAppIdentity } from "./secret-bootstrap-crypto.mjs";
12
12
  import { materializeDeclaredPlaceholders } from "./sealed-members.mjs";
13
13
  import { defaultSops } from "./secret-sops.mjs";
14
14
  import {
15
+ mainCheckout,
15
16
  outsideRepository,
16
17
  privateDirectory,
17
18
  secretRoots,
18
19
  } from "./secret-files.mjs";
19
20
 
20
- const PATH_RULE = String.raw`\.starcistacks[/\\][^/\\]+[/\\]secrets[/\\][^/\\]+\.enc$`;
21
+ const PATH_RULE = String.raw`\.starcistacks[/\\][^/\\]+[/\\]secrets[/\\][^/\\]+\.enc(?:\.yaml)?$`;
22
+ /** The rule an earlier init wrote, which matches legacy `.enc` members only; init and recipients treat it as the app's own. */
23
+ const LEGACY_PATH_RULE = String.raw`\.starcistacks[/\\][^/\\]+[/\\]secrets[/\\][^/\\]+\.enc$`;
24
+ /** The path_regex of one environment's rule: its members, current or legacy. */
25
+ export const environmentRule = (environment) =>
26
+ String.raw`\.starcistacks[/\\]` + environment + String.raw`[/\\]secrets[/\\][^/\\]+\.enc(?:\.yaml)?$`;
21
27
  const AGE_IDENTITY_NAME = /^SOPS_AGE_KEY(?:_FILE)?$/i;
22
28
 
23
29
  /** Bootstrap a new app with its own age identity; only its public recipient leaves this owner. */
@@ -63,22 +69,46 @@ export function initializeAppIdentity(repoRoot, { write, tools } = {}) {
63
69
  }
64
70
  }
65
71
 
66
- /** Load host-local age selections below the explicit environment, without copying any other credentials. */
67
- export function ageIdentityEnv(repoRoot, env, tools) {
72
+ /**
73
+ * The app's host-local age selection: SOPS_AGE_KEY or SOPS_AGE_KEY_FILE of the explicit environment, else of the git-ignored
74
+ * `.claude/secret.env` of the app root, else of its main checkout's when the root is a git worktree, else the SOPS_AGE_KEY the
75
+ * `host` port reads (the harness CLI's secret.env: `{ label, read }`, read only when nothing before it selects one). `env` is the explicit
76
+ * environment with that selection added below it (no other credential is copied); `source` names where it came from, or is
77
+ * null; `looked` lists every secret.env read, for a refusal that names them. No value ever leaves in `source` or `looked`.
78
+ */
79
+ export function localAgeIdentity(repoRoot, env, tools, host = null) {
80
+ const own = Object.keys(env).filter((name) => AGE_IDENTITY_NAME.test(name));
81
+ if (own.length) return { env: { ...env }, source: `the environment (${own.join(", ")})`, looked: [] };
68
82
  const load = tools?.secretEnv ?? secretEnv;
69
- let resolved = {};
70
- try {
71
- resolved = load(path.join(repoRoot, ".claude"), env) ?? {};
72
- } catch {
73
- return { ...env };
83
+ const roots = [...new Set([repoRoot, mainCheckout(repoRoot)].map((root) => path.resolve(root)))];
84
+ const looked = [];
85
+ for (const root of roots) {
86
+ const file = path.join(root, ".claude", "secret.env");
87
+ looked.push(file);
88
+ let resolved;
89
+ try {
90
+ resolved = load(path.join(root, ".claude"), env) ?? {};
91
+ } catch {
92
+ continue;
93
+ }
94
+ const local = Object.fromEntries(
95
+ Object.entries(resolved).filter(([name]) => AGE_IDENTITY_NAME.test(name)),
96
+ );
97
+ if (Object.keys(local).length)
98
+ return { env: { ...env, ...local }, source: `${file} (${Object.keys(local).join(", ")})`, looked };
74
99
  }
75
- const own = new Set(Object.keys(env).map((name) => name.toUpperCase()));
76
- const local = Object.fromEntries(
77
- Object.entries(resolved).filter(
78
- ([name]) => AGE_IDENTITY_NAME.test(name) && !own.has(name.toUpperCase()),
79
- ),
80
- );
81
- return { ...env, ...local };
100
+ if (host) {
101
+ looked.push(host.label);
102
+ const value = host.read();
103
+ if (typeof value === "string" && value.trim())
104
+ return { env: { ...env, SOPS_AGE_KEY: value.trim() }, source: `${host.label} (SOPS_AGE_KEY)`, looked };
105
+ }
106
+ return { env: { ...env }, source: null, looked };
107
+ }
108
+
109
+ /** Load host-local age selections below the explicit environment (localAgeIdentity), without copying any other credentials. */
110
+ export function ageIdentityEnv(repoRoot, env, tools) {
111
+ return localAgeIdentity(repoRoot, env, tools).env;
82
112
  }
83
113
 
84
114
  /** Select the first matching public age rule, including for SOPS's isolated CI identity path. */
@@ -98,12 +128,16 @@ export function configuredRecipients(repoRoot, file) {
98
128
  throw new Error(
99
129
  ".sops.yaml requires creation_rules; run starci app secret init",
100
130
  );
131
+ // A `.enc.yaml` member also matches a rule written for the legacy `.enc` spelling, so an app's existing .sops.yaml keeps
132
+ // sealing until `starci app secret recipients` rewrites it.
133
+ const spellings = [relative, ...(relative.endsWith(".enc.yaml") ? [relative.slice(0, -".yaml".length)] : [])];
101
134
  const rule = config.creation_rules.find((entry) => {
102
135
  try {
103
136
  return (
104
137
  !entry.path_regex ||
105
- new RegExp(entry.path_regex).test(relative) ||
106
- new RegExp(entry.path_regex).test(relative.replaceAll("/", "\\"))
138
+ spellings.some((spelling) =>
139
+ new RegExp(entry.path_regex).test(spelling) ||
140
+ new RegExp(entry.path_regex).test(spelling.replaceAll("/", "\\")))
107
141
  );
108
142
  } catch {
109
143
  throw new Error(".sops.yaml has an invalid path_regex");
@@ -163,7 +197,7 @@ function readInitializationConfig(configFile) {
163
197
  function existingInitializationRule(rules) {
164
198
  const canonical = rules.find(
165
199
  (rule) =>
166
- rule?.path_regex === PATH_RULE &&
200
+ [PATH_RULE, LEGACY_PATH_RULE].includes(rule?.path_regex) &&
167
201
  typeof rule.age === "string" &&
168
202
  rule.age.trim(),
169
203
  );
@@ -250,8 +284,73 @@ function initializationRecipient(repoRoot, env, platform, tool, tools) {
250
284
  return result.publicRecipient;
251
285
  }
252
286
 
287
+ /**
288
+ * The public recipient of the owner's age identity, as localAgeIdentity finds it (`host` its last source). Only an inline
289
+ * SOPS_AGE_KEY is derived; with none the caller names the recipient itself.
290
+ */
291
+ export function ownerRecipient(repoRoot, env, { platform, tools, host = null }) {
292
+ const local = localAgeIdentity(repoRoot, env, tools, host);
293
+ if (!Object.keys(local.env).some((name) => /^SOPS_AGE_KEY$/i.test(name)))
294
+ throw new Error(
295
+ `no SOPS_AGE_KEY found (looked in the environment${local.looked.length ? `, ${local.looked.join(", ")}` : ""}); name the owner's public recipient (age1...) instead of owner`,
296
+ );
297
+ return initializationRecipient(repoRoot, local.env, platform, secretTools(env, tools), tools);
298
+ }
299
+
300
+ /**
301
+ * `starci app secret recipients <env> [--add <age1...|owner>] [--remove <age1...|owner>]`: prints the public recipients a
302
+ * member of the environment is sealed to, or rewrites the environment's own rule of the app's managed .sops.yaml (one rule per
303
+ * environment, written before the app-wide rule, public keys only). `owner` is the owner identity's recipient (ownerRecipient).
304
+ * A member sealed before keeps its recipients until it is sealed again.
305
+ */
306
+ export function environmentRecipients(repoRoot, environment, { add = [], remove = [], owner, stdout }) {
307
+ const configFile = path.join(repoRoot, ".sops.yaml");
308
+ const config = readInitializationConfig(configFile);
309
+ const rules = Array.isArray(config.creation_rules) ? config.creation_rules : [];
310
+ const probe = path.join(repoRoot, ".starcistacks", environment, "secrets", "member.enc.yaml");
311
+ let current = [];
312
+ try { current = configuredRecipients(repoRoot, probe); } catch { current = []; }
313
+ if (!add.length && !remove.length) {
314
+ stdout(`${environment}: ${current.join(", ") || "no rule names a recipient; run starci app secret init"}\n`);
315
+ return 0;
316
+ }
317
+ let ownerValue;
318
+ const resolve = (value) => {
319
+ if (value !== "owner") return value;
320
+ ownerValue ??= owner();
321
+ return ownerValue;
322
+ };
323
+ const next = [...current];
324
+ for (const value of add.map(resolve)) {
325
+ if (!/^age1[a-z0-9]+$/u.test(value)) throw new Error(`${value} is not a public age recipient (age1...)`);
326
+ if (!next.includes(value)) next.push(value);
327
+ }
328
+ for (const value of remove.map(resolve)) {
329
+ const at = next.indexOf(value);
330
+ if (at < 0) throw new Error(`${environment} is not sealed to ${value}`);
331
+ next.splice(at, 1);
332
+ }
333
+ if (!next.length) throw new Error(`${environment} keeps at least one recipient`);
334
+ const regex = environmentRule(environment);
335
+ const own = rules.findIndex((rule) => rule?.path_regex === regex);
336
+ if (own >= 0) rules[own] = { ...rules[own], age: next.join(",") };
337
+ else {
338
+ const appWide = rules.findIndex((rule) => !rule?.path_regex || [PATH_RULE, LEGACY_PATH_RULE].includes(rule.path_regex));
339
+ rules.splice(appWide < 0 ? rules.length : appWide, 0, { path_regex: regex, age: next.join(",") });
340
+ }
341
+ config.creation_rules = rules;
342
+ fs.writeFileSync(
343
+ configFile,
344
+ `# Managed by starci app secret init and recipients: one rule per environment, public age recipients only.\n${stringifyYaml(config)}`,
345
+ );
346
+ stdout(
347
+ `${environment}: ${next.join(", ")}; a member sealed before keeps its recipients until it is sealed again (starci app secret migrate ${environment} <member> --execute)\n`,
348
+ );
349
+ return 0;
350
+ }
351
+
253
352
  /** Reuse an existing matching age rule; initialization must not rotate an app's recipient. */
254
- export function initializeSecrets(repoRoot, { env, platform, tools, stdout }) {
353
+ export function initializeSecrets(repoRoot, { env, platform, tools, stdout, host = null }) {
255
354
  const configFile = path.join(repoRoot, ".sops.yaml");
256
355
  const config = readInitializationConfig(configFile);
257
356
  const rules = config.creation_rules ?? [];
@@ -264,7 +363,7 @@ export function initializeSecrets(repoRoot, { env, platform, tools, stdout }) {
264
363
  throw new Error(
265
364
  ".sops.yaml must name valid public age recipients; initialization never prints private key material",
266
365
  );
267
- const localEnv = ageIdentityEnv(repoRoot, {}, tools);
366
+ const localEnv = localAgeIdentity(repoRoot, {}, tools, host).env;
268
367
  const selectedEnv = ageIdentityEnv(repoRoot, existing ? env : { ...env, ...localEnv }, tools);
269
368
  if (!existing && !Object.keys(localEnv).some((name) => /^SOPS_AGE_KEY$/i.test(name))) {
270
369
  const initialized = initializeAppIdentity(repoRoot, { tools });
@@ -276,7 +375,7 @@ export function initializeSecrets(repoRoot, { env, platform, tools, stdout }) {
276
375
  const inline = Object.keys(selectedEnv).some((name) => /^SOPS_AGE_KEY$/i.test(name));
277
376
  const derived = existing && !inline ? existing.age : initializationRecipient(repoRoot, selectedEnv, platform, tool, tools);
278
377
  const recipient = existing?.age ?? derived;
279
- const exact = rules.findIndex((rule) => rule?.path_regex === PATH_RULE);
378
+ const exact = rules.findIndex((rule) => [PATH_RULE, LEGACY_PATH_RULE].includes(rule?.path_regex));
280
379
  if (exact >= 0) rules[exact] = { ...rules[exact], age: recipient };
281
380
  // SOPS uses the first matching rule: keep any existing environment-specific recipients in force.
282
381
  else rules.push({ path_regex: PATH_RULE, age: recipient });
@@ -5,6 +5,7 @@ import { byCodeUnit } from "../lib/list.mjs";
5
5
  import { materializedDirectory, writeSecretFile } from "./secret-files.mjs";
6
6
  import { envelopeOf, validateKey } from "./secret-document.mjs";
7
7
  import { fileOf, readMap, sealedFiles } from "./secret-store.mjs";
8
+ import { memberOfName } from "../lib/sealed-member.mjs";
8
9
  import { SecretError, publicStorage } from "./secret-errors.mjs";
9
10
 
10
11
  function validateWindowsKeys(map) {
@@ -72,7 +73,7 @@ export function materializeSecrets(directory, sops, output) {
72
73
  const map = Object.create(null);
73
74
  for (const name of sealedFiles(directory)) {
74
75
  for (const [item, value] of Object.entries(
75
- readMap(fileOf(directory, name.slice(0, -4)), sops),
76
+ readMap(fileOf(directory, memberOfName(name)), sops),
76
77
  )) {
77
78
  if (Object.hasOwn(map, item))
78
79
  throw new SecretError(