@starci/hfs 4.0.10 → 4.2.0

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 (275) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/emit/operations.mjs +1 -1
  3. package/emit/type-schema.mjs +2 -1
  4. package/lint/run.mjs +65 -24
  5. package/package.json +1 -1
  6. package/report/order.mjs +2 -0
  7. package/runtime/config.example.yaml +194 -0
  8. package/runtime/engine/by-code-unit.mjs +12 -0
  9. package/runtime/engine/config.mjs +222 -239
  10. package/runtime/engine/invalid-config.mjs +5 -5
  11. package/runtime/engine/model-config.mjs +90 -0
  12. package/runtime/engine/orca-config.mjs +3 -1
  13. package/runtime/engine/release-config.mjs +28 -0
  14. package/runtime/engine/removed-vocabulary.mjs +28 -0
  15. package/runtime/engine/resources-config.mjs +52 -0
  16. package/runtime/engine/runtime-root.mjs +17 -0
  17. package/runtime/engine/secrets.mjs +35 -31
  18. package/runtime/engine/sonar-config.mjs +21 -0
  19. package/runtime/engine/temp-root.mjs +32 -0
  20. package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
  21. package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
  22. package/runtime/knowledge/hfs/rules.yaml +161 -65
  23. package/runtime/knowledge/hfs/slots.yaml +11 -14
  24. package/runtime/knowledge/patterns/be/api.yaml +7 -24
  25. package/runtime/knowledge/patterns/be/cli.yaml +3 -12
  26. package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
  27. package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
  28. package/runtime/modules/kernel/failure-codes.yaml +1 -41
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +268 -0
  30. package/runtime/modules/models/registry.yaml +25 -359
  31. package/runtime/modules/models/runtimes.yaml +126 -265
  32. package/runtime/modules/models/tiers.yaml +66 -0
  33. package/runtime/scripts/api/fs/claim-file.mjs +30 -0
  34. package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
  35. package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
  36. package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
  37. package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
  38. package/runtime/scripts/api/git/lib.mjs +34 -1
  39. package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
  40. package/runtime/scripts/api/process/run-program.mjs +2 -1
  41. package/runtime/scripts/api/sops/lib.mjs +198 -120
  42. package/runtime/scripts/hfs/allows.mjs +3 -3
  43. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  44. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  45. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  46. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  47. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  48. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  49. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  50. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  51. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  52. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  53. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  54. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  55. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  56. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  57. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  58. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  59. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  60. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  61. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  62. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  63. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  64. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  65. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  66. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  67. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  68. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
  69. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  70. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  71. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  72. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  73. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  74. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  75. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  76. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  77. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  78. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  79. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  80. package/runtime/scripts/hfs/architecture/index.mjs +164 -104
  81. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  82. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  83. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  84. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  85. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  86. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  87. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  88. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  89. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  90. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  91. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  92. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  93. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  94. package/runtime/scripts/hfs/architecture/required-files.mjs +121 -70
  95. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  96. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  97. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  98. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  99. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  100. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  101. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  102. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  103. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  104. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  105. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  106. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  107. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  108. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  109. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  110. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  111. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  112. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  113. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  114. package/runtime/scripts/hfs/architecture.mjs +3 -4
  115. package/runtime/scripts/hfs/catalog-once.mjs +15 -0
  116. package/runtime/scripts/hfs/check.mjs +63 -71
  117. package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
  118. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  119. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  120. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  121. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  122. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  123. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  124. package/runtime/scripts/hfs/pin-findings.mjs +31 -0
  125. package/runtime/scripts/hfs/repo-identity.mjs +21 -4
  126. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  127. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  128. package/runtime/scripts/hfs/rules/cli.mjs +5 -1
  129. package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
  130. package/runtime/scripts/hfs/rules/contract.mjs +35 -27
  131. package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
  132. package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
  133. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
  134. package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
  135. package/runtime/scripts/hfs/rules/database.mjs +36 -23
  136. package/runtime/scripts/hfs/rules/deps.mjs +64 -32
  137. package/runtime/scripts/hfs/rules/docker.mjs +113 -65
  138. package/runtime/scripts/hfs/rules/edition.mjs +118 -111
  139. package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
  140. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
  141. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  142. package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
  143. package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
  144. package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
  145. package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
  146. package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
  147. package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
  148. package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
  149. package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
  150. package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
  151. package/runtime/scripts/hfs/rules/saga.mjs +128 -61
  152. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  153. package/runtime/scripts/hfs/rules/services.mjs +34 -16
  154. package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
  155. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
  156. package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
  157. package/runtime/scripts/hfs/secret.mjs +59 -48
  158. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  159. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  160. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  161. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  162. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  163. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  164. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  165. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  166. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  167. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  168. package/runtime/scripts/hfs/slots.mjs +74 -695
  169. package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
  170. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  171. package/runtime/scripts/hfs/tree.mjs +19 -14
  172. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  173. package/runtime/scripts/hfs/view.mjs +1 -1
  174. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  175. package/runtime/scripts/lib/env.mjs +10 -0
  176. package/runtime/scripts/lib/event-contract.mjs +17 -13
  177. package/runtime/scripts/lib/git.mjs +2 -2
  178. package/runtime/scripts/lib/graphql-contract.mjs +158 -322
  179. package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
  180. package/runtime/scripts/lib/i18n.mjs +3 -2
  181. package/runtime/scripts/lib/in-order.mjs +71 -0
  182. package/runtime/scripts/lib/language.mjs +3 -3
  183. package/runtime/scripts/lib/list.mjs +8 -1
  184. package/runtime/scripts/lib/path-key.mjs +38 -5
  185. package/runtime/scripts/lib/pid-alive.mjs +7 -0
  186. package/runtime/scripts/lib/regex.mjs +1 -1
  187. package/runtime/scripts/lib/same-text.mjs +1 -1
  188. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  189. package/runtime/scripts/lib/sleep-sync.mjs +2 -2
  190. package/runtime/scripts/lib/sops-envelope.mjs +95 -48
  191. package/runtime/scripts/lib/stack-services.mjs +1 -2
  192. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  193. package/runtime/scripts/lib/walk.mjs +6 -1
  194. package/runtime/scripts/lib/yaml-cached.mjs +14 -0
  195. package/scaffold/add-cli-lite.mjs +2 -1
  196. package/scaffold/add-table.mjs +2 -2
  197. package/scaffold/add.mjs +3 -2
  198. package/scaffold/app.mjs +4 -2
  199. package/scaffold/edition-gate.mjs +25 -25
  200. package/scaffold/lite-exports.mjs +2 -1
  201. package/scaffold/service.mjs +5 -4
  202. package/sync/hygiene.mjs +15 -9
  203. package/sync/index.mjs +7 -3
  204. package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
  205. package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
  206. package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
  207. package/templates/app/ci-workflows/sonar-steps.yml +2 -2
  208. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
  209. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
  210. package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
  211. package/templates/app/starciwork.gitignore +1 -1
  212. package/templates/be/image/api/Dockerfile +1 -1
  213. package/templates/be/image/cli/Dockerfile +1 -1
  214. package/templates/be/image/worker/Dockerfile +1 -1
  215. package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
  216. package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
  217. package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
  218. package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
  219. package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
  220. package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
  221. package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
  222. package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
  223. package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
  224. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
  225. package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
  226. package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
  227. package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
  228. package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
  229. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
  230. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
  231. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
  232. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
  233. package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
  234. package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
  235. package/templates/fe/image/next/Dockerfile +2 -1
  236. package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
  237. package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
  238. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
  239. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
  240. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
  241. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
  242. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
  243. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
  244. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
  245. package/upgrade/index.mjs +9 -2
  246. package/runtime/engine/admission.mjs +0 -308
  247. package/runtime/engine/canonical-json.mjs +0 -10
  248. package/runtime/engine/db/blob.mjs +0 -315
  249. package/runtime/engine/db/ledger-paths.mjs +0 -83
  250. package/runtime/engine/db/ledger.mjs +0 -1118
  251. package/runtime/engine/db/machine-connection.mjs +0 -135
  252. package/runtime/engine/db/machine-schema.mjs +0 -84
  253. package/runtime/engine/db/machine.mjs +0 -1367
  254. package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
  255. package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
  256. package/runtime/engine/db/provider-reservations.mjs +0 -101
  257. package/runtime/engine/digest.mjs +0 -16
  258. package/runtime/engine/refuse.mjs +0 -11
  259. package/runtime/modules/ops/_labels.yaml +0 -53
  260. package/runtime/scripts/api/node/lib.mjs +0 -14
  261. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  262. package/runtime/scripts/api/process/lib.mjs +0 -111
  263. package/runtime/scripts/api/process/owned-process.mjs +0 -78
  264. package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
  265. package/runtime/scripts/connectors/lib.mjs +0 -488
  266. package/runtime/scripts/lib/clip.mjs +0 -19
  267. package/runtime/scripts/lib/display-names.mjs +0 -259
  268. package/runtime/scripts/lib/example-refs.mjs +0 -158
  269. package/runtime/scripts/lib/json-schema.mjs +0 -52
  270. package/runtime/scripts/lib/process-identity.mjs +0 -6
  271. package/runtime/scripts/lib/read-yaml.mjs +0 -18
  272. package/runtime/scripts/lib/redact.mjs +0 -161
  273. package/runtime/scripts/lib/sleep.mjs +0 -7
  274. package/runtime/scripts/lib/source-phrases.mjs +0 -34
  275. package/runtime/scripts/lib/sqlite.mjs +0 -21
@@ -29,8 +29,8 @@ export function secretFileFindings({ file, text }) {
29
29
  const findings = [];
30
30
  const lines = text.split(/\r?\n/);
31
31
  const seen = new Set();
32
- for (let index = 0; index < lines.length; index += 1) {
33
- for (const pattern of secretHits(file, lines[index])) {
32
+ for (const [index, line] of lines.entries()) {
33
+ for (const pattern of secretHits(file, line)) {
34
34
  if (seen.has(pattern)) continue;
35
35
  seen.add(pattern);
36
36
  findings.push(found(PLAINTEXT_SECRET, file, `${file}:${index + 1} holds a plaintext secret (${pattern}); seal it with sops at .starcistacks/<env>/secrets/<slug>.enc and read it by *_FILE`, { line: index + 1, pattern }));
@@ -45,7 +45,8 @@ export function secretFindings({ repoRoot, files, resolver }) {
45
45
  for (const file of files) {
46
46
  const slot = resolver.classifyPath(file);
47
47
  if (slot.status === 'forbidden' && slotOwnsSecrets(resolver.slot(slot.slot))) {
48
- findings.push(found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret file tracked in ${slot.slot}${slot.goesTo ? `; it belongs at ${slot.goesTo}` : ''}`, { slot: slot.slot, goesTo: slot.goesTo }));
48
+ const destination = slot.goesTo ? `; it belongs at ${slot.goesTo}` : '';
49
+ findings.push(found(PLAINTEXT_SECRET, file, `${file} is a plaintext secret file tracked in ${slot.slot}${destination}`, { slot: slot.slot, goesTo: slot.goesTo }));
49
50
  continue;
50
51
  }
51
52
  findings.push(...secretFileFindings({ file, text: readText(repoRoot, file) }));
@@ -20,6 +20,7 @@ import { loadTypescript } from '../../lib/package-at.mjs';
20
20
  import { propertyText } from '../../lib/ts-ast.mjs';
21
21
  import { folded, readEventClasses, snapshotText } from '../../lib/event-contract.mjs';
22
22
  import { found, readText } from './read.mjs';
23
+ import { byCodeUnit } from '../../lib/list.mjs';
23
24
 
24
25
  const SERVICE_PLACEMENT = 'HFS_SERVICE_PLACEMENT';
25
26
  const IMAGE_UNPINNED = 'HFS_IMAGE_UNPINNED';
@@ -68,7 +69,7 @@ function servicePlacementFindings({ files }) {
68
69
  /** The image reference with `${VAR:-default}` resolved to its default, or null when it cannot be (a bare `${VAR}`). */
69
70
  function resolvedImage(image) {
70
71
  let unresolved = false;
71
- const resolved = image.replace(/\$\{[^}:]+(?::-([^}]*))?\}/g, (_all, fallback) => { if (fallback === undefined) unresolved = true; return fallback ?? ''; });
72
+ const resolved = image.replace(/\$\{[^}:]+(?::-([^}]*))?\}/g, (_all, fallback) => { if (fallback === undefined) { unresolved = true; } return fallback ?? ''; });
72
73
  return unresolved ? null : resolved;
73
74
  }
74
75
 
@@ -126,7 +127,7 @@ function serviceStackFindings({ repoRoot, repo }) {
126
127
  function eventClassesOf({ repoRoot, files, ts }) {
127
128
  const classes = [];
128
129
  const problems = [];
129
- for (const file of files.filter((candidate) => EVENT_CLASS_FILE.test(candidate)).sort()) {
130
+ for (const file of files.filter((candidate) => EVENT_CLASS_FILE.test(candidate)).sort(byCodeUnit)) {
130
131
  const service = EVENT_CLASS_FILE.exec(file)[1];
131
132
  const read = readEventClasses(ts, readText(repoRoot, file) ?? '', file);
132
133
  for (const event of read.events) classes.push({ service, file, ...event });
@@ -135,6 +136,35 @@ function eventClassesOf({ repoRoot, files, ts }) {
135
136
  return { classes, problems };
136
137
  }
137
138
 
139
+ /** Findings for each service whose vendored `events.json` is missing or differs from what its event classes emit. */
140
+ function snapshotDriftFindings(repoRoot, tracked, services, classes) {
141
+ const findings = [];
142
+ for (const service of services) {
143
+ const snapshot = `be/contracts/${service}/events.json`;
144
+ const events = classes.filter((event) => event.service === service);
145
+ if (!tracked.has(snapshot)) findings.push(found(EVENT_CONTRACT, snapshot, `${service} declares event classes (be/src/modules/events/${service}/) but ${snapshot} is not committed; run \`npm run contract:emit\` and commit the snapshot, the contract its consumers rely on.`, { service }));
146
+ else if (folded(readText(repoRoot, snapshot) ?? '') !== snapshotText(service, events)) findings.push(found(EVENT_CONTRACT, snapshot, `${snapshot} differs from what the event classes of ${service} emit now; run \`npm run contract:emit\` and commit the result.`, { service, drift: 'stale' }));
147
+ }
148
+ return findings;
149
+ }
150
+
151
+ /** Findings for committed snapshots of a service that declares no event class. */
152
+ function leftBehindSnapshotFindings(snapshots, services, problems) {
153
+ const findings = [];
154
+ for (const snapshot of snapshots) {
155
+ const service = SNAPSHOT.exec(snapshot)[1];
156
+ if (!services.includes(service) && !problems.some((entry) => entry.service === service)) findings.push(found(EVENT_CONTRACT, snapshot, `${snapshot} is committed but be/src/modules/events/${service}/ declares no event class; delete the snapshot or restore the classes.`, { service, drift: 'left-behind' }));
157
+ }
158
+ return findings;
159
+ }
160
+
161
+ /** Findings for compensating events that name an event no class declares. */
162
+ function undeclaredCompensationFindings(classes, declared) {
163
+ return classes
164
+ .filter((event) => event.compensates !== undefined && !declared.has(event.compensates))
165
+ .map((event) => found(EVENT_CONTRACT, event.file, `${event.file}: event "${event.name}" compensates "${event.compensates}", which no event class declares; name the event whose step it undoes.`, { service: event.service, event: event.name }));
166
+ }
167
+
138
168
  /** Findings of R166: each service's event classes are the one source of its vendored `events.json`, and a compensating event names a declared event. */
139
169
  function eventContractFindings({ repoRoot, files }) {
140
170
  const classFiles = files.filter((file) => EVENT_CLASS_FILE.test(file));
@@ -145,21 +175,9 @@ function eventContractFindings({ repoRoot, files }) {
145
175
  const tracked = new Set(files);
146
176
  const { classes, problems } = eventClassesOf({ repoRoot, files, ts });
147
177
  const findings = problems.map(({ service, file, problem }) => found(EVENT_CONTRACT, file, `${file} is not an event class the contract can be emitted from: ${problem}.`, { service }));
148
- const services = [...new Set(classes.map((event) => event.service))].sort();
178
+ const services = [...new Set(classes.map((event) => event.service))].sort(byCodeUnit);
149
179
  const declared = new Set(classes.map((event) => event.name));
150
- for (const service of services) {
151
- const snapshot = `be/contracts/${service}/events.json`;
152
- const events = classes.filter((event) => event.service === service);
153
- if (!tracked.has(snapshot)) findings.push(found(EVENT_CONTRACT, snapshot, `${service} declares event classes (be/src/modules/events/${service}/) but ${snapshot} is not committed; run \`npm run contract:emit\` and commit the snapshot, the contract its consumers rely on.`, { service }));
154
- else if (folded(readText(repoRoot, snapshot) ?? '') !== snapshotText(service, events)) findings.push(found(EVENT_CONTRACT, snapshot, `${snapshot} differs from what the event classes of ${service} emit now; run \`npm run contract:emit\` and commit the result.`, { service, drift: 'stale' }));
155
- }
156
- for (const snapshot of snapshots) {
157
- const service = SNAPSHOT.exec(snapshot)[1];
158
- if (!services.includes(service) && !problems.some((entry) => entry.service === service)) findings.push(found(EVENT_CONTRACT, snapshot, `${snapshot} is committed but be/src/modules/events/${service}/ declares no event class; delete the snapshot or restore the classes.`, { service, drift: 'left-behind' }));
159
- }
160
- for (const event of classes) {
161
- if (event.compensates !== undefined && !declared.has(event.compensates)) findings.push(found(EVENT_CONTRACT, event.file, `${event.file}: event "${event.name}" compensates "${event.compensates}", which no event class declares; name the event whose step it undoes.`, { service: event.service, event: event.name }));
162
- }
180
+ findings.push(...snapshotDriftFindings(repoRoot, tracked, services, classes), ...leftBehindSnapshotFindings(snapshots, services, problems), ...undeclaredCompensationFindings(classes, declared));
163
181
  return findings;
164
182
  }
165
183
 
@@ -4,7 +4,7 @@
4
4
  // secrets/<slug>.enc, seeds}); a `.enc` outside <env>/secrets/ is never allowed, `runtime/files/`, a root `DESIGN.md`,
5
5
  // `deployment.json` and `k8s/` are not in the list, so they fall out of it;
6
6
  // - the declaration (read by scripts/lib/stack-declaration.mjs, the one reader of it) states a sonar service, a local
7
- // Sonar is owned by the host (`stack.owner: host`, root `.claude/ext/sonar`), and no service still points at `.stacks`.
7
+ // Sonar is owned by the host (`stack.owner: host`, root `.claude/ext/sonar`).
8
8
  // The declaration's services contract (custody, CI wiring, project keys) stays check-starcistacks's; this file judges shape only.
9
9
  import { braceVariants, globExpression } from '../../lib/glob.mjs';
10
10
  import { declaredStack, findStackDeclaration, STACK_ROOT, text } from '../../lib/stack-declaration.mjs';
@@ -13,31 +13,29 @@ import { found } from './read.mjs';
13
13
  const STACKS_SHAPE = 'HFS_STACKS_SHAPE';
14
14
  const STACKS_SLOT = 'app.starcistacks';
15
15
  const HOST_SONAR_ROOT = '.claude/ext/sonar';
16
- const SEALED = /\.enc$/;
17
16
  const INSIDE_SECRETS = /^[^/]+\/secrets\/[^/]+\.enc$/;
18
- const RETIRED_ROOT = /^\.?stacks(?:\/|$)/;
19
17
 
20
18
  /** The anchored expressions of a slot's `allows` entries: `<name>` is one path segment, braces alternate. */
21
19
  const allowedExpressions = (allows) => allows.flatMap((entry) => braceVariants(entry.replace(/<[a-z][a-z0-9-]*>/g, '*'))).map((variant) => globExpression(variant.endsWith('/') ? `${variant}**` : variant));
22
20
 
23
21
  const shapeFinding = (file, message, extra) => found(STACKS_SHAPE, file, message, extra);
24
22
 
25
- /** The shape findings of the `.starcistacks` tree at the app root (`repoRoot` is the app root, `files` its own tracked paths). */
26
- export function stacksFindings({ repoRoot, files, resolver }) {
27
- const slot = resolver.slot(STACKS_SLOT);
28
- if (!slot) return [];
23
+ function treeFindings(files, allowed, prefix) {
29
24
  const findings = [];
30
- const allowed = allowedExpressions(slot.allows ?? []);
31
- const prefix = `${STACK_ROOT}/`;
32
25
  for (const file of files.filter((f) => f.startsWith(prefix))) {
33
26
  const rel = file.slice(prefix.length);
34
- if (SEALED.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/`));
27
+ 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/`));
35
28
  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`));
36
29
  }
37
- if (!files.some((f) => f === `${prefix}application-stacks.yaml`)) return findings;
38
- const declaration = findStackDeclaration(repoRoot);
30
+ return findings;
31
+ }
32
+
33
+ function declarationFindings(repoRoot, files, prefix) {
39
34
  const declared = `${prefix}application-stacks.yaml`;
40
- if (declaration.error) return [...findings, shapeFinding(declared, `${declared} cannot be read: ${declaration.error}`)];
35
+ if (!files.includes(declared)) return [];
36
+ const findings = [];
37
+ const declaration = findStackDeclaration(repoRoot);
38
+ if (declaration.error) return [shapeFinding(declared, `${declared} cannot be read: ${declaration.error}`)];
41
39
  const services = declaration.doc?.services;
42
40
  if (services === undefined || typeof services.sonar !== 'object' || services.sonar === null) {
43
41
  findings.push(shapeFinding(declared, `${declared} declares no sonar service; every repository states its Sonar owner (a local one is owned by the host: stack.owner host, root ${HOST_SONAR_ROOT})`));
@@ -45,10 +43,19 @@ export function stacksFindings({ repoRoot, files, resolver }) {
45
43
  }
46
44
  for (const [id, entry] of Object.entries(services)) {
47
45
  const stack = declaredStack(entry);
48
- if (stack?.root && RETIRED_ROOT.test(stack.root)) findings.push(shapeFinding(declared, `${declared} services.${id}.stack.root ${stack.root} still points at the retired .stacks root; the stack root is ${STACK_ROOT}`));
49
46
  if (id === 'sonar' && text(entry?.mode) === 'local' && !(stack?.hostOwned && stack.root === HOST_SONAR_ROOT)) {
50
47
  findings.push(shapeFinding(declared, `${declared} services.sonar is a local Sonar but is not owned by the host; declare stack.owner host with root ${HOST_SONAR_ROOT}`));
51
48
  }
52
49
  }
53
50
  return findings;
54
51
  }
52
+
53
+ /** The shape findings of the `.starcistacks` tree at the app root (`repoRoot` is the app root, `files` its own tracked paths). */
54
+ export function stacksFindings({ repoRoot, files, resolver }) {
55
+ const slot = resolver.slot(STACKS_SLOT);
56
+ if (!slot) return [];
57
+ const prefix = `${STACK_ROOT}/`;
58
+ const findings = treeFindings(files, allowedExpressions(slot.allows ?? []), prefix);
59
+ findings.push(...declarationFindings(repoRoot, files, prefix));
60
+ return findings;
61
+ }
@@ -46,33 +46,42 @@ const liteSecretPatterns = line => {
46
46
 
47
47
  const inSecretFreeTree = (repo, file) => repo.profile === 'fe' || file.startsWith('fe/') || file.startsWith('supabase/');
48
48
 
49
+ function custodyPathFinding(file, repo, findings) {
50
+ if (repo.profile === 'app') {
51
+ if (ROOT_ENV.test(file) || SECRETS_ENV.test(file) || SECRETS_DIRECTORY.test(file)) {
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
+ return true;
54
+ }
55
+ if (file.endsWith('.enc') && !SEALED_HOME.test(file)) {
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
+ }
58
+ } else if (file.endsWith('.enc')) {
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
+ }
61
+ return false;
62
+ }
63
+
64
+ function providerSecretFindings(repoRoot, file, repo, findings) {
65
+ if (!inSecretFreeTree(repo, file) || LOCKFILE.test(file)) return;
66
+ const text = readText(repoRoot, file);
67
+ if (text === null) return;
68
+ const seen = new Set();
69
+ const lines = text.split(/\r?\n/u);
70
+ for (const [index, line] of lines.entries()) {
71
+ for (const pattern of liteSecretPatterns(line)) {
72
+ if (seen.has(pattern)) continue;
73
+ seen.add(pattern);
74
+ findings.push(found(LITE_SECRET_CUSTODY, file, `${file}:${index + 1} holds a provider-shaped secret (${pattern}); lite keeps no secret literal under fe/ or supabase/.`, { line: index + 1, pattern }));
75
+ }
76
+ }
77
+ }
78
+
49
79
  /** Raw L17 findings. The catalog-driven findings filter decides which editions judge them. */
50
80
  export function liteSecretCustodyFindings({ repoRoot, files, repo }) {
51
81
  const findings = [];
52
82
  for (const file of files) {
53
- if (repo.profile === 'app') {
54
- if (ROOT_ENV.test(file) || SECRETS_ENV.test(file) || SECRETS_DIRECTORY.test(file)) {
55
- 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' }));
56
- continue;
57
- }
58
- if (file.endsWith('.enc') && !SEALED_HOME.test(file)) {
59
- 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' }));
60
- }
61
- } else if (file.endsWith('.enc')) {
62
- 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' }));
63
- }
64
- if (!inSecretFreeTree(repo, file) || LOCKFILE.test(file)) continue;
65
- const text = readText(repoRoot, file);
66
- if (text === null) continue;
67
- const seen = new Set();
68
- const lines = text.split(/\r?\n/u);
69
- for (let index = 0; index < lines.length; index += 1) {
70
- for (const pattern of liteSecretPatterns(lines[index])) {
71
- if (seen.has(pattern)) continue;
72
- seen.add(pattern);
73
- findings.push(found(LITE_SECRET_CUSTODY, file, `${file}:${index + 1} holds a provider-shaped secret (${pattern}); lite keeps no secret literal under fe/ or supabase/.`, { line: index + 1, pattern }));
74
- }
75
- }
83
+ if (custodyPathFinding(file, repo, findings)) continue;
84
+ providerSecretFindings(repoRoot, file, repo, findings);
76
85
  }
77
86
  return findings;
78
87
  }
@@ -3,29 +3,43 @@
3
3
  // - there is no `testing/` folder: doubles and builders live in `src/tests/fixtures/`, next to the subject as `*.spec.ts`;
4
4
  // - one `jest.config.js` at the root: a second jest config (`jest.config.<x>.js`, `jest.<x>.config.*`, one per app) and a
5
5
  // `jest` key in a package.json are refused.
6
- // `int-spec`, `harness-spec`, the retired test folders and the per-lane configs under src/tests are the architecture machine's
6
+ // `int-spec`, `harness-spec`, the unsupported test folders and the per-lane configs under src/tests are the architecture machine's
7
7
  // (reported under this same code, BE_TEST_TOPOLOGY); that jest.config.js is the rendered one (projects `unit`, `integration`, `e2e` and
8
8
  // `contract`, diagnostics off) is the managed-file check's (HFS_MANAGED_FILE_DRIFT); a spec whose suffix disagrees with its src/tests folder has no slot (HFS_SLOT_UNDECLARED).
9
9
  import { found, readJson } from './read.mjs';
10
10
 
11
11
  const TEST_TOPOLOGY = 'BE_TEST_TOPOLOGY';
12
12
  const DOT_TEST = /\.test\.[cm]?[jt]sx?$/;
13
- const JEST_CONFIG = /(?:^|\/)jest(?:\.[^/]+)*\.config(?:\.[^/]+)*\.(?:[cm]?[jt]s|json)$|(?:^|\/)jest\.config\.[^/]+$/;
13
+ const JEST_CONFIG_NAMED = /(?:^|\/)jest(?:\.[^/]+)?\.config(?:\.[^/]+)?\.(?:[cm]?[jt]s|json)$/;
14
+ const JEST_CONFIG_ANY_EXTENSION = /(?:^|\/)jest\.config\.[^/]+$/;
14
15
  const ROOT_JEST_CONFIG = 'jest.config.js';
15
16
 
17
+ const isJestConfig = (file) => JEST_CONFIG_NAMED.test(file) || JEST_CONFIG_ANY_EXTENSION.test(file);
18
+
19
+ /** The finding of the tracked file's own path, else null. */
20
+ function pathFinding(file) {
21
+ if (DOT_TEST.test(file)) return found(TEST_TOPOLOGY, file, `${file} is a \`.test\` file; a unit spec is <name>.spec.ts beside its subject and an integration, e2e or contract spec is *.integration-spec.ts, *.e2e-spec.ts or *.contract-spec.ts under its own src/tests folder`);
22
+ if (file.split('/').slice(0, -1).includes('testing')) return found(TEST_TOPOLOGY, file, `${file} sits in a testing/ folder; doubles and builders live in src/tests/fixtures/ and specs sit beside their subject`);
23
+ if (isJestConfig(file) && file !== ROOT_JEST_CONFIG) return found(TEST_TOPOLOGY, file, `${file} is a second jest configuration; the one root ${ROOT_JEST_CONFIG} declares the projects unit, integration, e2e and contract`);
24
+ return null;
25
+ }
26
+
27
+ /** The finding of a package.json that carries a `jest` key, else null. */
28
+ function packageFinding(repoRoot, file) {
29
+ if (file !== 'package.json' && !file.endsWith('/package.json')) return null;
30
+ if (file.includes('node_modules/')) return null;
31
+ const pkg = readJson(repoRoot, file);
32
+ return pkg && Object.hasOwn(pkg, 'jest') ? found(TEST_TOPOLOGY, file, `${file} carries a jest key; jest is configured only by the root ${ROOT_JEST_CONFIG}`) : null;
33
+ }
34
+
16
35
  /** The findings of R47 over the tracked paths `files` of a back-end repository at `repoRoot`. */
17
36
  export function testTopologyFindings({ repoRoot, files }) {
18
37
  const findings = [];
19
38
  for (const file of files) {
20
- const segments = file.split('/');
21
- if (DOT_TEST.test(file)) findings.push(found(TEST_TOPOLOGY, file, `${file} is a \`.test\` file; a unit spec is <name>.spec.ts beside its subject and an integration, e2e or contract spec is *.integration-spec.ts, *.e2e-spec.ts or *.contract-spec.ts under its own src/tests folder`));
22
- else if (segments.slice(0, -1).includes('testing')) findings.push(found(TEST_TOPOLOGY, file, `${file} sits in a testing/ folder; doubles and builders live in src/tests/fixtures/ and specs sit beside their subject`));
23
- else if (JEST_CONFIG.test(file) && file !== ROOT_JEST_CONFIG) findings.push(found(TEST_TOPOLOGY, file, `${file} is a second jest configuration; the one root ${ROOT_JEST_CONFIG} declares the projects unit, integration, e2e and contract`));
24
- if (file === 'package.json' || file.endsWith('/package.json')) {
25
- if (file.includes('node_modules/')) continue;
26
- const pkg = readJson(repoRoot, file);
27
- if (pkg && Object.hasOwn(pkg, 'jest')) findings.push(found(TEST_TOPOLOGY, file, `${file} carries a jest key; jest is configured only by the root ${ROOT_JEST_CONFIG}`));
28
- }
39
+ const own = pathFinding(file);
40
+ if (own) findings.push(own);
41
+ const packaged = packageFinding(repoRoot, file);
42
+ if (packaged) findings.push(packaged);
29
43
  }
30
44
  return findings;
31
45
  }
@@ -1,6 +1,6 @@
1
1
  // secret.mjs - `starci app secret list|show|set|gen`: the canon command over an app's sealed secrets.
2
2
  // (R06: a secret exists only as a sops envelope there). It is the one way an operator or an agent reads or writes one; a plain
3
- // `sops` command in a KEYS.md or a runbook is retired. Every sops use goes through scripts/api/sops (decrypt.mjs, seal.mjs):
3
+ // `sops` command in a KEYS.md or a runbook is not the way. Every sops use goes through scripts/api/sops (decrypt.mjs, seal.mjs):
4
4
  // starci app secret list [--env <name>] every secret of the env and the keys it holds (names only, nothing decrypted)
5
5
  // starci app secret show <slug> [--key <NAME>] [--env <name>] decrypt one value to stdout
6
6
  // starci app secret set <slug> [--key <NAME>] [--age <recipient>]... [--env <name>]
@@ -16,18 +16,23 @@ import { runProgram } from '../api/process/run-program.mjs';
16
16
  import { resolveRealTool } from '../api/process/resolve-real-tool.mjs';
17
17
  const sopsInvocation = Object.freeze({runProgram,resolveRealTool});
18
18
  import { randomBytes, randomUUID } from 'node:crypto';
19
- import { claimManager } from '../connectors/lib.mjs';
20
- import { sha256 } from '../../engine/digest.mjs';
19
+ import { claimFile } from '../api/fs/claim-file.mjs';
21
20
  import { isSopsEnvelope } from '../lib/sops-envelope.mjs';
22
21
  import { decrypt as sopsDecrypt } from '../api/sops/decrypt.mjs';
23
22
  import { seal as sopsSeal } from '../api/sops/seal.mjs';
23
+ import { byCodeUnit } from '../lib/list.mjs';
24
24
 
25
25
  class SecretError extends Error {}
26
- // claimManager is process-owned; refuse a recursive same-process write as well.
27
- const writingSecrets = new Set();
28
26
 
29
27
  const SLUG = /^[a-z0-9][a-z0-9-]*$/u;
30
- const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u;
28
+ const KEY = /^[A-Za-z_]\w*$/u;
29
+ const WHITESPACE = String.raw`\s`;
30
+ const REQUIRED_INDENT = `${WHITESPACE}+`;
31
+ const OPTIONAL_DASH = '-?';
32
+ const OPTIONAL_SPACING = `${WHITESPACE}*`;
33
+ const RECIPIENT_NAME = 'recipient:';
34
+ const RECIPIENT_VALUE = String.raw`(\S+)`;
35
+ const RECIPIENT_LINE = new RegExp(`^${REQUIRED_INDENT}${OPTIONAL_DASH}${OPTIONAL_SPACING}${RECIPIENT_NAME}${OPTIONAL_SPACING}${RECIPIENT_VALUE}`, 'gmu');
31
36
  const DEFAULT_KEY = 'data';
32
37
  const VERBS = new Set(['list', 'show', 'set', 'gen']);
33
38
 
@@ -47,19 +52,19 @@ export function envelopeOf(text) {
47
52
  return { format, keys: Object.keys(doc).filter((name) => name !== 'sops'), recipients: (doc.sops?.age ?? []).map((entry) => entry.recipient).filter(Boolean) };
48
53
  }
49
54
  if (format === 'yaml') {
50
- const keys = [...text.matchAll(/^([A-Za-z_][A-Za-z0-9_]*):/gmu)].map((match) => match[1]).filter((name) => name !== 'sops');
51
- return { format, keys, recipients: [...text.matchAll(/^\s+-?\s*recipient:\s*(\S+)/gmu)].map((match) => match[1]) };
55
+ const keys = [...text.matchAll(/^([A-Za-z_]\w*):/gmu)].map((match) => match[1]).filter((name) => name !== 'sops');
56
+ return { format, keys, recipients: [...text.matchAll(RECIPIENT_LINE)].map((match) => match[1]) };
52
57
  }
53
- const keys = [...text.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)=/gmu)].map((match) => match[1]).filter((name) => !name.startsWith('sops_'));
58
+ const keys = [...text.matchAll(/^([A-Za-z_]\w*)=/gmu)].map((match) => match[1]).filter((name) => !name.startsWith('sops_'));
54
59
  return { format, keys, recipients: [...text.matchAll(/^sops_age__list_\d+__map_recipient=(\S+)/gmu)].map((match) => match[1]) };
55
60
  }
56
61
 
57
62
  /** The plaintext document `map` in the given format. A dotenv value cannot hold a line break. */
58
63
  export function plaintextOf(format, map) {
59
64
  if (format === 'json') return `${JSON.stringify(map)}\n`;
60
- if (format === 'yaml') return `${Object.entries(map).map(([name, value]) => `${name}: ${JSON.stringify(value)}`).join('\n')}\n`;
65
+ if (format === 'yaml') return Object.entries(map).map(([name, value]) => name + ': ' + JSON.stringify(value)).join('\n') + '\n';
61
66
  for (const [name, value] of Object.entries(map)) if (/[\r\n]/u.test(value)) throw new SecretError(`${name} holds a line break; a dotenv secret holds one-line values only`);
62
- return `${Object.entries(map).map(([name, value]) => `${name}=${value}`).join('\n')}\n`;
67
+ return Object.entries(map).map(([name, value]) => name + '=' + value).join('\n') + '\n';
63
68
  }
64
69
 
65
70
  /** The directory of the sealed secrets of the app at `repoRoot` for `env`; the one env that has secrets when none is named. */
@@ -118,14 +123,10 @@ function parse(argv) {
118
123
  const readStdin = () => fs.readFileSync(0, 'utf8');
119
124
 
120
125
  /** Seal `key = value` into the secret `slug`: an existing document keeps its other keys and its recipients. */
121
- function writeSecret({ file, values, age, sops, processEnv = process.env }) {
126
+ function writeSecret({ file, values, age, sops }) {
122
127
  for (const key of Object.keys(values)) if (!KEY.test(key)) throw new SecretError(`${key} is not a key name`);
123
- const absolute = path.resolve(file), target = process.platform === 'win32' ? absolute.toLowerCase() : absolute;
124
- const name = `sealed-secret-${sha256(target)}`;
125
- if (writingSecrets.has(name)) throw new SecretError('the sealed secret is being updated; retry after that write settles');
126
- const held = claimManager(name, { env: processEnv });
128
+ const held = claimFile(path.resolve(file));
127
129
  if (!held.ok) throw new SecretError('the sealed secret is being updated; retry after that write settles');
128
- writingSecrets.add(name);
129
130
  try {
130
131
  let format = 'json';
131
132
  let map = {};
@@ -148,7 +149,7 @@ function writeSecret({ file, values, age, sops, processEnv = process.env }) {
148
149
  fs.writeFileSync(temporary, sealed, { flag: 'wx', mode: 0o600, flush: true });
149
150
  fs.renameSync(temporary, file);
150
151
  } finally { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); }
151
- } finally { writingSecrets.delete(name); held.release(); }
152
+ } finally { held.release(); }
152
153
  }
153
154
 
154
155
  /** Update named keys through the same canonical sealed-secret owner used by the CLI. */
@@ -157,10 +158,45 @@ export function setSecretValues(repoRoot, { slug, values, env = null }, { sops =
157
158
  const file = fileOf(directory, slug);
158
159
  if (!values || typeof values !== 'object' || Array.isArray(values) || !Object.keys(values).length) throw new SecretError('secret update requires named values');
159
160
  for (const value of Object.values(values)) if (typeof value !== 'string' || value === '') throw new SecretError('secret values must be nonempty strings');
160
- writeSecret({ file, values, age, sops: sops ?? defaultSops(processEnv), processEnv });
161
+ writeSecret({ file, values, age, sops: sops ?? defaultSops(processEnv) });
161
162
  return { file, via: 'sealed-secret' };
162
163
  }
163
164
 
165
+ function listSecrets(opts, directory, repoRoot, stdout) {
166
+ if (opts.positional.length) throw new SecretError('starci app secret list takes no name');
167
+ const files = fs.existsSync(directory) ? fs.readdirSync(directory).filter((name) => name.endsWith('.enc')).sort(byCodeUnit) : [];
168
+ for (const name of files) stdout(`${name.slice(0, -'.enc'.length)} ${envelopeOf(fs.readFileSync(path.join(directory, name), 'utf8')).keys.join(', ')}\n`);
169
+ stdout(`${files.length} secret${files.length === 1 ? '' : 's'} in ${path.relative(repoRoot, directory).split(path.sep).join('/')}\n`);
170
+ return 0;
171
+ }
172
+
173
+ function showSecret(opts, file, seam, stdout) {
174
+ if (!fs.existsSync(file)) throw new SecretError(`${opts.positional[0]} is not sealed in this env`);
175
+ const { format, keys } = envelopeOf(fs.readFileSync(file, 'utf8'));
176
+ if (opts.key === undefined && !keys.includes(DEFAULT_KEY) && keys.length !== 1) throw new SecretError(`${opts.positional[0]} holds ${keys.join(', ')}; name one with --key`);
177
+ const map = seam.decrypt(file, format);
178
+ const name = opts.key ?? (keys.includes(DEFAULT_KEY) ? DEFAULT_KEY : keys[0]);
179
+ if (!Object.hasOwn(map, name)) throw new SecretError(`${opts.positional[0]} has no key ${name}`);
180
+ stdout(`${String(map[name])}\n`);
181
+ return 0;
182
+ }
183
+
184
+ function setSecretFromStdin(opts, file, key, seam, stdin, stdout) {
185
+ const value = String(stdin()).replace(/\r?\n$/u, '');
186
+ if (value === '') throw new SecretError('the value on stdin is empty');
187
+ writeSecret({ file, values: { [key]: value }, age: opts.age, sops: seam });
188
+ stdout(`sealed ${opts.positional[0]} (${key})\n`);
189
+ return 0;
190
+ }
191
+
192
+ function generateSecret(opts, file, key, seam, random, stdout) {
193
+ const bytes = opts.bytes === undefined ? 32 : Number(opts.bytes);
194
+ if (!Number.isInteger(bytes) || bytes < 16 || bytes > 1024) throw new SecretError('--bytes is a whole number from 16 to 1024');
195
+ writeSecret({ file, values: { [key]: random(bytes).toString('base64url') }, age: opts.age, sops: seam });
196
+ stdout(`generated and sealed ${opts.positional[0]} (${key}, ${bytes} bytes); read it with starci app secret show\n`);
197
+ return 0;
198
+ }
199
+
164
200
  /** `starci app secret <verb> ...`; `stdin` returns the value `set` seals, `sops` and `random` are test seams. */
165
201
  export function secretMain(argv, { stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin = readStdin, sops = null, random = randomBytes, env = process.env, cwd = process.cwd() } = {}) {
166
202
  const [verb, ...rest] = argv;
@@ -170,38 +206,13 @@ export function secretMain(argv, { stdout = (s) => process.stdout.write(s), stde
170
206
  const repoRoot = path.resolve(cwd, opts.cwd ?? '.');
171
207
  const directory = secretsDirectory(repoRoot, opts.env);
172
208
  const key = opts.key ?? DEFAULT_KEY;
173
- if (verb === 'list') {
174
- if (opts.positional.length) throw new SecretError('starci app secret list takes no name');
175
- const files = fs.existsSync(directory) ? fs.readdirSync(directory).filter((name) => name.endsWith('.enc')).sort() : [];
176
- for (const name of files) stdout(`${name.slice(0, -'.enc'.length)} ${envelopeOf(fs.readFileSync(path.join(directory, name), 'utf8')).keys.join(', ')}\n`);
177
- stdout(`${files.length} secret${files.length === 1 ? '' : 's'} in ${path.relative(repoRoot, directory).split(path.sep).join('/')}\n`);
178
- return 0;
179
- }
209
+ if (verb === 'list') return listSecrets(opts, directory, repoRoot, stdout);
180
210
  if (opts.positional.length !== 1) throw new SecretError(`starci app secret ${verb} takes one secret name`);
181
211
  const file = fileOf(directory, opts.positional[0]);
182
212
  const seam = sops ?? defaultSops(env);
183
- if (verb === 'show') {
184
- if (!fs.existsSync(file)) throw new SecretError(`${opts.positional[0]} is not sealed in this env`);
185
- const { format, keys } = envelopeOf(fs.readFileSync(file, 'utf8'));
186
- if (opts.key === undefined && !keys.includes(DEFAULT_KEY) && keys.length !== 1) throw new SecretError(`${opts.positional[0]} holds ${keys.join(', ')}; name one with --key`);
187
- const map = seam.decrypt(file, format);
188
- const name = opts.key ?? (keys.includes(DEFAULT_KEY) ? DEFAULT_KEY : keys[0]);
189
- if (!Object.hasOwn(map, name)) throw new SecretError(`${opts.positional[0]} has no key ${name}`);
190
- stdout(`${String(map[name])}\n`);
191
- return 0;
192
- }
193
- if (verb === 'set') {
194
- const value = String(stdin()).replace(/\r?\n$/u, '');
195
- if (value === '') throw new SecretError('the value on stdin is empty');
196
- writeSecret({ file, values: { [key]: value }, age: opts.age, sops: seam, processEnv: env });
197
- stdout(`sealed ${opts.positional[0]} (${key})\n`);
198
- return 0;
199
- }
200
- const bytes = opts.bytes === undefined ? 32 : Number(opts.bytes);
201
- if (!Number.isInteger(bytes) || bytes < 16 || bytes > 1024) throw new SecretError('--bytes is a whole number from 16 to 1024');
202
- writeSecret({ file, values: { [key]: random(bytes).toString('base64url') }, age: opts.age, sops: seam, processEnv: env });
203
- stdout(`generated and sealed ${opts.positional[0]} (${key}, ${bytes} bytes); read it with starci app secret show\n`);
204
- return 0;
213
+ if (verb === 'show') return showSecret(opts, file, seam, stdout);
214
+ if (verb === 'set') return setSecretFromStdin(opts, file, key, seam, stdin, stdout);
215
+ return generateSecret(opts, file, key, seam, random, stdout);
205
216
  } catch (error) {
206
217
  if (error instanceof SecretError || error instanceof SyntaxError) { stderr(`starci app secret: ${error.message}\n`); return 2; }
207
218
  throw error;
@@ -0,0 +1,102 @@
1
+ // slot-app-view.mjs - the resolver of a whole app: a root path is answered by the scope of the app root, a path below a side folder
2
+ // (be/, fe/) by that side's scope with the side prefixed back onto every path and root it returns. Only the declared `reads` cross sides.
3
+ // slots.mjs builds the scopes (`root` and `sides`, each a resolver of one scope) and hands them to appResolver.
4
+ import { APP_SCOPE, PROFILES } from './slot-manifest-shape.mjs';
5
+ import { cleanSlotPath } from './slot-path.mjs';
6
+
7
+ /** { side, rest } when `p` lies below a side folder, else null. */
8
+ function splitSide(input) {
9
+ const p = cleanSlotPath(input);
10
+ const slash = p.indexOf('/');
11
+ const head = slash < 0 ? p : p.slice(0, slash);
12
+ return PROFILES.includes(head) && slash > 0 ? { side: head, rest: p.slice(slash + 1) } : null;
13
+ }
14
+
15
+ const under = (side, rel) => (rel ? `${side}/${rel}` : side);
16
+
17
+ function prefixed(side, c) {
18
+ return {
19
+ ...c,
20
+ path: under(side, c.path),
21
+ side,
22
+ ...(c.root !== undefined ? { root: under(side, c.root) } : {}),
23
+ ...(c.nearest ? { nearest: { ...c.nearest, matchedPrefix: under(side, c.nearest.matchedPrefix), matchedDepth: c.nearest.matchedDepth + 1 } } : {}),
24
+ };
25
+ }
26
+
27
+ function classifyApp({ root, sides }, input) {
28
+ const at = splitSide(input);
29
+ return at ? prefixed(at.side, sides[at.side].classifyPath(at.rest)) : root.classifyPath(input);
30
+ }
31
+
32
+ function ownerApp({ root, sides }, input) {
33
+ const at = splitSide(input);
34
+ if (!at) return root.ownerOf(input);
35
+ const owner = sides[at.side].ownerOf(at.rest);
36
+ return owner ? { ...owner, root: under(at.side, owner.root), side: at.side } : null;
37
+ }
38
+
39
+ /** Whether `toPath`, of the other side, lies below a path `fromSide` declares it reads. */
40
+ const readsPath = (repo, fromSide, toPath) => repo.sides[fromSide].reads.some((read) => `${cleanSlotPath(toPath)}/`.startsWith(read));
41
+
42
+ /** The verdict of an import from a side file to a path of the other side (or of the app root, `toSide` null): allowed only through a declared read. */
43
+ function sideCrossing(repo, fromSide, toSide, toPath) {
44
+ return readsPath(repo, fromSide, toPath)
45
+ ? { allowed: true, reason: 'sideRead', fromSide, toSide }
46
+ : { allowed: false, reason: 'crossSide', fromSide, toSide, reads: repo.sides[fromSide].reads };
47
+ }
48
+
49
+ function importAllowedApp({ repo, root, sides }, fromPath, toPath) {
50
+ const from = splitSide(fromPath);
51
+ const to = splitSide(toPath);
52
+ // A side file may read an app-root path (supabase/types/) only through a declared read, exactly as it reads the other side.
53
+ if (from && from.side !== to?.side) return sideCrossing(repo, from.side, to?.side ?? null, toPath);
54
+ if (from && to) return sides[from.side].importAllowed(from.rest, to.rest);
55
+ return root.importAllowed(fromPath, toPath);
56
+ }
57
+
58
+ function requiredPathsApp({ root, sides }) {
59
+ const own = root.requiredPaths();
60
+ const paths = [...own.paths];
61
+ const minimums = [...own.minimums];
62
+ for (const side of PROFILES) {
63
+ const required = sides[side].requiredPaths();
64
+ paths.push(...required.paths.map((entry) => ({ ...entry, path: under(side, entry.path), side })));
65
+ minimums.push(...required.minimums.map((entry) => ({ ...entry, side })));
66
+ }
67
+ return { paths, minimums };
68
+ }
69
+
70
+ function slotEnabledApp({ root, sides }, slot) {
71
+ if (slot.profiles.includes(APP_SCOPE)) return root.slotEnabled(slot);
72
+ return PROFILES.some((side) => slot.profiles.includes(side) && sides[side].slotEnabled(slot));
73
+ }
74
+
75
+ /** The resolver of the app `repo`: `root` answers its root paths and `sides[side]` the paths below that side's folder. */
76
+ export function appResolver(repo, root, sides) {
77
+ const view = { repo, root, sides };
78
+ const allSlots = [...new Map([...root.slots(), ...PROFILES.flatMap((side) => sides[side].slots())].map((s) => [s.id, s])).values()];
79
+ const byId = new Map(allSlots.map((s) => [s.id, s]));
80
+ const classifyPath = (input) => classifyApp(view, input);
81
+ return Object.freeze({
82
+ repo,
83
+ sides: Object.freeze(sides),
84
+ /** The side of a path (be, fe) or null for a root path. */
85
+ sideOf: (input) => splitSide(input)?.side ?? null,
86
+ slot: (id) => byId.get(id) ?? null,
87
+ slots: () => allSlots,
88
+ slotEnabled: (slot) => slotEnabledApp(view, slot),
89
+ classifyPath,
90
+ slotOf: (p) => { const c = classifyPath(p); return c.slot ? byId.get(c.slot) : null; },
91
+ ownerOf: (input) => ownerApp(view, input),
92
+ tierOf: (input) => { const at = splitSide(input); return at ? sides[at.side].tierOf(at.rest) : root.tierOf(input); },
93
+ importAllowed: (fromPath, toPath) => importAllowedApp(view, fromPath, toPath),
94
+ requiredFiles: (input) => { const at = splitSide(input); return at ? sides[at.side].requiredFiles(at.rest).map((p) => under(at.side, p)) : root.requiredFiles(input); },
95
+ requiredPaths: () => requiredPathsApp(view),
96
+ trackingOf: (p) => classifyPath(p).tracking ?? null,
97
+ isTracked: (p) => classifyPath(p).tracking === 'tracked',
98
+ // The root has no tier and no rule parameters of its own; each side has them (sides.<side>.ruleParams()).
99
+ ruleParams: () => null,
100
+ allowedImports: () => null,
101
+ });
102
+ }