@starci/hfs 4.3.0 → 4.4.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 (515) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/db/migrate.mjs +361 -0
  3. package/db/post-cutover.mjs +136 -0
  4. package/emit/db-types.mjs +10 -1
  5. package/lint/run.mjs +1 -1
  6. package/package.json +4 -1
  7. package/release/gate.mjs +298 -0
  8. package/release/index.mjs +103 -0
  9. package/release/push.mjs +110 -0
  10. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  11. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  12. package/runtime/knowledge/hfs/rules.yaml +3401 -152
  13. package/runtime/knowledge/hfs/slots.yaml +344 -62
  14. package/runtime/knowledge/patterns/be/api.yaml +32 -5
  15. package/runtime/knowledge/patterns/be/cli.yaml +14 -4
  16. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  17. package/runtime/knowledge/patterns/be/jobs.yaml +20 -2
  18. package/runtime/knowledge/patterns/be/projections.yaml +4 -2
  19. package/runtime/knowledge/patterns/be/queues.yaml +11 -3
  20. package/runtime/knowledge/patterns/be/reactors.yaml +20 -1
  21. package/runtime/knowledge/patterns/be/realtime.yaml +25 -3
  22. package/runtime/knowledge/patterns/be/saga.yaml +20 -3
  23. package/runtime/knowledge/patterns/be/webhooks.yaml +23 -4
  24. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  25. package/runtime/knowledge/sonar-gate.yaml +1 -1
  26. package/runtime/modules/kernel/failure-codes.yaml +3113 -43
  27. package/runtime/modules/models/runtimes.yaml +1 -1
  28. package/runtime/modules/schemas/application-stacks.schema.yaml +663 -0
  29. package/runtime/packages/cli/src/catalog.generated.mjs +14104 -0
  30. package/runtime/packages/cli/src/validate-args.mjs +213 -0
  31. package/runtime/scripts/api/git/add.mjs +5 -0
  32. package/runtime/scripts/api/git/init.mjs +7 -0
  33. package/runtime/scripts/api/git/lib.mjs +3 -0
  34. package/runtime/scripts/api/git/tag.mjs +5 -0
  35. package/runtime/scripts/api/node/lib.mjs +20 -0
  36. package/runtime/scripts/api/node/run-node.mjs +6 -0
  37. package/runtime/scripts/api/sops/lib.mjs +41 -0
  38. package/runtime/scripts/api/sops/selected-age-recipient.mjs +7 -0
  39. package/runtime/scripts/api/supabase/database-dump.mjs +5 -0
  40. package/runtime/scripts/api/supabase/database-query.mjs +5 -0
  41. package/runtime/scripts/api/supabase/lib.mjs +44 -0
  42. package/runtime/scripts/api/supabase/migration-repair.mjs +5 -0
  43. package/runtime/scripts/hfs/app-wiring.mjs +201 -0
  44. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  45. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  46. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  47. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  48. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  49. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  50. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  51. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  52. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  53. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  54. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  55. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  56. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  57. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  58. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  59. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  60. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  61. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  62. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  63. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  64. package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
  65. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  66. package/runtime/scripts/hfs/check-results.mjs +28 -0
  67. package/runtime/scripts/hfs/check.mjs +46 -47
  68. package/runtime/scripts/hfs/coverage-from.mjs +70 -0
  69. package/runtime/scripts/hfs/db-baseline-verb.mjs +276 -0
  70. package/runtime/scripts/hfs/db-plan-verb.mjs +254 -0
  71. package/runtime/scripts/hfs/db-restamp-verb.mjs +60 -0
  72. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  73. package/runtime/scripts/hfs/doc-commands.mjs +367 -0
  74. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  75. package/runtime/scripts/hfs/example-secrets.mjs +158 -0
  76. package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
  77. package/runtime/scripts/hfs/refusal-codes.mjs +6 -0
  78. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  79. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  80. package/runtime/scripts/hfs/rules/a15-findings.mjs +88 -0
  81. package/runtime/scripts/hfs/rules/app-env-required.mjs +20 -0
  82. package/runtime/scripts/hfs/rules/app-root.mjs +2 -2
  83. package/runtime/scripts/hfs/rules/assets.mjs +79 -0
  84. package/runtime/scripts/hfs/rules/boot-proof.mjs +16 -0
  85. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +59 -0
  86. package/runtime/scripts/hfs/rules/contract.mjs +66 -5
  87. package/runtime/scripts/hfs/rules/coverage-below.mjs +230 -0
  88. package/runtime/scripts/hfs/rules/database-access.mjs +343 -0
  89. package/runtime/scripts/hfs/rules/database-baseline.mjs +142 -0
  90. package/runtime/scripts/hfs/rules/database-config.mjs +250 -2
  91. package/runtime/scripts/hfs/rules/database-constants.mjs +9 -0
  92. package/runtime/scripts/hfs/rules/database-migrations.mjs +286 -7
  93. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +119 -9
  94. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +176 -0
  95. package/runtime/scripts/hfs/rules/database-queues.mjs +361 -0
  96. package/runtime/scripts/hfs/rules/database-sql.mjs +91 -9
  97. package/runtime/scripts/hfs/rules/database.mjs +9 -0
  98. package/runtime/scripts/hfs/rules/deploy-scan.mjs +192 -0
  99. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +219 -0
  100. package/runtime/scripts/hfs/rules/doc-fresh.mjs +286 -0
  101. package/runtime/scripts/hfs/rules/docker-health.mjs +249 -0
  102. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  103. package/runtime/scripts/hfs/rules/edition.mjs +9 -8
  104. package/runtime/scripts/hfs/rules/env-parity.mjs +373 -0
  105. package/runtime/scripts/hfs/rules/env-value-shape.mjs +113 -0
  106. package/runtime/scripts/hfs/rules/event-bus.mjs +3 -2
  107. package/runtime/scripts/hfs/rules/event-tables.mjs +100 -0
  108. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +115 -15
  109. package/runtime/scripts/hfs/rules/fe-stack.mjs +57 -0
  110. package/runtime/scripts/hfs/rules/feature-files.mjs +28 -0
  111. package/runtime/scripts/hfs/rules/frontend-tree.mjs +54 -35
  112. package/runtime/scripts/hfs/rules/integration-specs.mjs +3 -2
  113. package/runtime/scripts/hfs/rules/lexicon.mjs +62 -0
  114. package/runtime/scripts/hfs/rules/lite-proof-lane.mjs +89 -0
  115. package/runtime/scripts/hfs/rules/monorepo.mjs +14 -1
  116. package/runtime/scripts/hfs/rules/pipeline.mjs +8 -1
  117. package/runtime/scripts/hfs/rules/release-provenance.mjs +211 -0
  118. package/runtime/scripts/hfs/rules/saga-delivery.mjs +66 -0
  119. package/runtime/scripts/hfs/rules/saga.mjs +8 -16
  120. package/runtime/scripts/hfs/rules/script-hygiene.mjs +151 -0
  121. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +65 -0
  122. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  123. package/runtime/scripts/hfs/rules/services.mjs +4 -4
  124. package/runtime/scripts/hfs/rules/stack-tooling.mjs +93 -0
  125. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +76 -0
  126. package/runtime/scripts/hfs/rules/stacks-ingress.mjs +87 -0
  127. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +111 -0
  128. package/runtime/scripts/hfs/rules/stacks-release.mjs +366 -0
  129. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +41 -1
  130. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +417 -0
  131. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  132. package/runtime/scripts/hfs/rules/supply-chain.mjs +167 -0
  133. package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +101 -0
  134. package/runtime/scripts/hfs/rules/unused-symbol-ledger.mjs +77 -0
  135. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +165 -0
  136. package/runtime/scripts/hfs/rules/unused-symbol-values.mjs +103 -0
  137. package/runtime/scripts/hfs/rules/unused-symbol.mjs +320 -0
  138. package/runtime/scripts/hfs/runtime-rules/source-ast.mjs +185 -0
  139. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +19 -0
  140. package/runtime/scripts/hfs/sealed-members.mjs +101 -0
  141. package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +200 -0
  142. package/runtime/scripts/hfs/secret-edit.mjs +170 -0
  143. package/runtime/scripts/hfs/secret-init.mjs +127 -19
  144. package/runtime/scripts/hfs/secret-sops.mjs +38 -6
  145. package/runtime/scripts/hfs/secret-store.mjs +56 -54
  146. package/runtime/scripts/hfs/secret.mjs +34 -5
  147. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  148. package/runtime/scripts/hfs/slots.mjs +1 -0
  149. package/runtime/scripts/hfs/stack-config-kit.mjs +34 -0
  150. package/runtime/scripts/hfs/stack-deploy-config.mjs +313 -0
  151. package/runtime/scripts/hfs/stack-ingress-config.mjs +237 -0
  152. package/runtime/scripts/hfs/stacks-gate.mjs +414 -0
  153. package/runtime/scripts/hfs/starcistacks-runtime.mjs +61 -0
  154. package/runtime/scripts/lib/command-tokens.mjs +126 -0
  155. package/runtime/scripts/lib/compose-values.mjs +11 -0
  156. package/runtime/scripts/lib/deployment-findings.mjs +7 -0
  157. package/runtime/scripts/lib/json-clone.mjs +61 -0
  158. package/runtime/scripts/lib/list.mjs +4 -0
  159. package/runtime/scripts/lib/runtime-example.mjs +13 -0
  160. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  161. package/runtime/scripts/lib/sql-ast.mjs +32 -0
  162. package/runtime/scripts/lib/stack-services.mjs +1 -1
  163. package/runtime/scripts/lib/swarm-stack.mjs +5 -18
  164. package/runtime/scripts/lib/trim-end.mjs +8 -0
  165. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  166. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  167. package/runtime/scripts/lib/workflow-events.mjs +8 -0
  168. package/scaffold/add-app.mjs +77 -59
  169. package/scaffold/add-cli-lite.mjs +14 -6
  170. package/scaffold/add-compose.mjs +79 -0
  171. package/scaffold/add-database-lite.mjs +14 -0
  172. package/scaffold/add-fe-overlay.mjs +122 -0
  173. package/scaffold/add-fe-page.mjs +269 -0
  174. package/scaffold/add-inbox-lite.mjs +74 -42
  175. package/scaffold/add-table.mjs +110 -87
  176. package/scaffold/add.mjs +159 -44
  177. package/scaffold/app.mjs +124 -39
  178. package/scaffold/edition-gate.mjs +3 -3
  179. package/scaffold/lite-exports.mjs +2 -1
  180. package/scaffold/service.mjs +2 -2
  181. package/src/main.mjs +229 -116
  182. package/stack/age-install.mjs +37 -0
  183. package/stack/backup.mjs +100 -0
  184. package/stack/build-args.mjs +42 -0
  185. package/stack/build.mjs +173 -0
  186. package/stack/cli.mjs +130 -0
  187. package/stack/config.mjs +57 -0
  188. package/stack/context.mjs +57 -0
  189. package/stack/converge.mjs +107 -0
  190. package/stack/deploy.mjs +301 -0
  191. package/stack/digest.mjs +16 -0
  192. package/stack/docker-transport.mjs +53 -0
  193. package/stack/edge-alias.mjs +118 -0
  194. package/stack/errors.mjs +21 -0
  195. package/stack/image-scan.mjs +152 -0
  196. package/stack/ingress-create.mjs +114 -0
  197. package/stack/ingress-publish.mjs +273 -0
  198. package/stack/ingress.mjs +706 -0
  199. package/stack/jobs.mjs +51 -0
  200. package/stack/live-restore.mjs +177 -0
  201. package/stack/prepare.mjs +266 -0
  202. package/stack/private-file.mjs +22 -0
  203. package/stack/provision.mjs +90 -0
  204. package/stack/prune.mjs +46 -0
  205. package/stack/release-store.mjs +87 -0
  206. package/stack/remote.mjs +205 -0
  207. package/stack/render.mjs +149 -0
  208. package/stack/retire.mjs +47 -0
  209. package/stack/sealed.mjs +149 -0
  210. package/stack/task.mjs +42 -0
  211. package/stack/verify.mjs +99 -0
  212. package/sync/ci-commands.mjs +13 -0
  213. package/sync/index.mjs +108 -52
  214. package/sync/stack-images.mjs +127 -0
  215. package/templates/app/ci-workflows/github/workflows/ci.yml +5 -1
  216. package/templates/app/ci-workflows/github/workflows/images.yml +15 -3
  217. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +148 -0
  218. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +7 -3
  219. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +38 -6
  220. package/templates/app/ci-workflows-lite/github/workflows/images.yml +14 -2
  221. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +148 -0
  222. package/templates/app/codegen-graphql.mjs.partial +13 -2
  223. package/templates/app/codegen-lite/scripts/codegen.mjs +6 -1
  224. package/templates/app/codegen-prelude.mjs.partial +8 -0
  225. package/templates/app/gitignore +3 -0
  226. package/templates/app/gitignore-lite +3 -0
  227. package/templates/app/package-scripts/package.json +1 -0
  228. package/templates/app/package-scripts/supabase.json +7 -0
  229. package/templates/app/package-scripts-lite/package.json +1 -7
  230. package/templates/app/skeleton/.editorconfig +1 -0
  231. package/templates/app/skeleton/.gitattributes +7 -1
  232. package/templates/app/skeleton/.github/dependabot.yml +17 -0
  233. package/templates/app/skeleton/.nvmrc +1 -1
  234. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +134 -1
  235. package/templates/app/skeleton/.starcistacks/dev/README.md +16 -0
  236. package/templates/app/skeleton/.starcistacks/dev/environment.json +5 -0
  237. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +71 -0
  238. package/templates/app/skeleton/.starcistacks/dev/runtime/env/KEYS.md +14 -0
  239. package/templates/app/skeleton/.starcistacks/production/README.md +37 -0
  240. package/templates/app/skeleton/.starcistacks/production/environment.json +5 -0
  241. package/templates/app/skeleton/.starcistacks/production/infra/swarm/Caddyfile +15 -0
  242. package/templates/app/skeleton/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
  243. package/templates/app/skeleton/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
  244. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
  245. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/images.lock.yaml +9 -0
  246. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
  247. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +142 -0
  248. package/templates/app/skeleton/.starcistacks/production/runtime/env/KEYS.md +14 -0
  249. package/templates/app/skeleton/README.md +6 -6
  250. package/templates/app/skeleton/scripts/codegen.mjs +1 -2
  251. package/templates/app/skeleton-lite/.editorconfig +16 -0
  252. package/templates/app/skeleton-lite/.gitattributes +8 -0
  253. package/templates/app/skeleton-lite/.github/dependabot.yml +17 -0
  254. package/templates/app/skeleton-lite/.nvmrc +1 -0
  255. package/templates/app/skeleton-lite/.sops.yaml +3 -2
  256. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +178 -1
  257. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +20 -0
  258. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +2 -1
  259. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +16 -0
  260. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +11 -0
  261. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +7 -0
  262. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +6 -0
  263. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +8 -0
  264. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +6 -0
  265. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +120 -0
  266. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -0
  267. package/templates/app/skeleton-lite/.starcistacks/production/README.md +38 -0
  268. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +6 -0
  269. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/Caddyfile +11 -0
  270. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
  271. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
  272. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
  273. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/images.lock.yaml +8 -0
  274. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
  275. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +121 -0
  276. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +21 -0
  277. package/templates/app/skeleton-lite/.starciwork/brand/index.yaml +12 -0
  278. package/templates/app/skeleton-lite/README.md +5 -3
  279. package/templates/app/skeleton-lite/supabase/config.toml +8 -1
  280. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +15 -6
  281. package/templates/app/upgrade-full/.starcistacks/dev/README.md +13 -7
  282. package/templates/be/image/worker/Dockerfile +1 -1
  283. package/templates/be/inbox/database-client.spec.ts.tpl +17 -18
  284. package/templates/be/inbox/database-client.ts.tpl +12 -11
  285. package/templates/be/inbox/delivery-recorded.event.ts.tpl +36 -0
  286. package/templates/be/inbox/event-index.ts.tpl +1 -0
  287. package/templates/be/inbox/events.json.tpl +10 -0
  288. package/templates/be/inbox/migration.sql.tpl +1 -0
  289. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +42 -0
  290. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +9 -0
  291. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +6 -0
  292. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +19 -0
  293. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +2 -0
  294. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +16 -0
  295. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +45 -0
  296. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +15 -0
  297. package/templates/be/inbox/platform/event-bus/index.ts.tpl +5 -0
  298. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +5 -0
  299. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +9 -0
  300. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +6 -0
  301. package/templates/be/inbox/platform/inbox/inbox.module.ts.tpl +19 -0
  302. package/templates/be/inbox/platform/inbox/inbox.options.ts.tpl +2 -0
  303. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +10 -0
  304. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +41 -0
  305. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +19 -0
  306. package/templates/be/inbox/platform/inbox/index.ts.tpl +3 -0
  307. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +7 -0
  308. package/templates/be/inbox/platform/migration.sql.tpl +48 -0
  309. package/templates/be/inbox/service.spec.ts.tpl +42 -9
  310. package/templates/be/inbox/service.ts.tpl +23 -8
  311. package/templates/be/inbox/webhook-door.spec.ts.tpl +1 -1
  312. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  313. package/templates/be/patterns/api/feature.module-definition.ts.tpl +6 -0
  314. package/templates/be/patterns/api/feature.module.ts.tpl +2 -1
  315. package/templates/be/patterns/api/feature.options.ts.tpl +2 -0
  316. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +6 -0
  317. package/templates/be/patterns/api/graphql.module.ts.tpl +3 -2
  318. package/templates/be/patterns/api/graphql.options.ts.tpl +2 -0
  319. package/templates/be/patterns/cli/group.module-definition.ts.tpl +6 -0
  320. package/templates/be/patterns/cli/group.module.ts.tpl +2 -1
  321. package/templates/be/patterns/cli/group.options.ts.tpl +2 -0
  322. package/templates/be/patterns/event-bus/platform/errors/event-bus.error.ts.tpl +0 -3
  323. package/templates/be/patterns/event-bus/platform/messages/event-bus.messages.ts.tpl +0 -2
  324. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +6 -0
  325. package/templates/be/patterns/jobs/job.module.ts.tpl +2 -1
  326. package/templates/be/patterns/jobs/job.options.ts.tpl +2 -0
  327. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +6 -0
  328. package/templates/be/patterns/jobs/job.queue.module.ts.tpl +6 -3
  329. package/templates/be/patterns/jobs/job.queue.options.ts.tpl +2 -0
  330. package/templates/be/patterns/jobs/platform/fenced.processor.spec.ts.tpl +3 -1
  331. package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +25 -9
  332. package/templates/be/patterns/queues/platform/queue.config.ts.tpl +1 -1
  333. package/templates/be/patterns/queues/platform/queue.index.ts.tpl +1 -1
  334. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +2 -2
  335. package/templates/be/patterns/queues/platform/queue.module.ts.tpl +11 -0
  336. package/templates/be/patterns/queues/platform/queue.options.ts.tpl +10 -0
  337. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +6 -0
  338. package/templates/be/patterns/queues/queue.module.ts.tpl +2 -1
  339. package/templates/be/patterns/queues/queue.options.ts.tpl +2 -0
  340. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +6 -0
  341. package/templates/be/patterns/reactors/message.module.ts.tpl +6 -3
  342. package/templates/be/patterns/reactors/message.options.ts.tpl +2 -0
  343. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +6 -0
  344. package/templates/be/patterns/reactors/reactor.module.ts.tpl +2 -1
  345. package/templates/be/patterns/reactors/reactor.options.ts.tpl +2 -0
  346. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +188 -0
  347. package/templates/be/patterns/realtime/channel.gateway.ts.tpl +22 -0
  348. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +6 -0
  349. package/templates/be/patterns/realtime/channel.graphql.module.ts.tpl +2 -1
  350. package/templates/be/patterns/realtime/channel.graphql.options.ts.tpl +2 -0
  351. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +6 -0
  352. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +110 -0
  353. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +7 -0
  354. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +6 -0
  355. package/templates/be/patterns/saga/saga.message.module.ts.tpl +6 -3
  356. package/templates/be/patterns/saga/saga.message.options.ts.tpl +2 -0
  357. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +6 -0
  358. package/templates/be/patterns/saga/saga.module.ts.tpl +2 -1
  359. package/templates/be/patterns/saga/saga.options.ts.tpl +2 -0
  360. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +6 -0
  361. package/templates/be/patterns/webhooks/webhook.http.module.ts.tpl +2 -1
  362. package/templates/be/patterns/webhooks/webhook.http.options.ts.tpl +2 -0
  363. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +80 -26
  364. package/templates/be/platform/graphql/localize-errors.mapper.ts +2 -5
  365. package/templates/be/skeleton/apps/__app__/src/app.module.ts +11 -9
  366. package/templates/be/skeleton/apps/__app__/src/compose/api.ts +4 -0
  367. package/templates/be/skeleton/apps/__app__/src/compose/domain.ts +11 -0
  368. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +2 -0
  369. package/templates/be/skeleton/apps/__app__/src/compose/system.ts +5 -0
  370. package/templates/be/skeleton/apps/__app__/src/compose/webhooks.ts +4 -0
  371. package/templates/be/skeleton/apps/__app__/src/main.ts +15 -1
  372. package/templates/be/skeleton/apps/cli/src/app.module.ts +3 -13
  373. package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -14
  374. package/templates/be/skeleton/apps/cli/src/compose/cli.ts +5 -0
  375. package/templates/be/skeleton/apps/cli/src/compose/platform.ts +24 -0
  376. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.contracts.ts +2 -0
  377. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.handler.ts +22 -0
  378. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.query.ts +11 -0
  379. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +6 -0
  380. package/templates/be/skeleton/src/features/api/system-health/system-health.module.ts +5 -3
  381. package/templates/be/skeleton/src/features/api/system-health/system-health.options.ts +2 -0
  382. package/templates/be/skeleton/src/features/api/system-health/transport/http/ready.controller.ts +19 -0
  383. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +6 -0
  384. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module.ts +8 -3
  385. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.options.ts +2 -0
  386. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +6 -0
  387. package/templates/be/skeleton/src/features/cli/cli.module.ts +3 -2
  388. package/templates/be/skeleton/src/features/cli/cli.options.ts +2 -0
  389. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +6 -0
  390. package/templates/be/skeleton/src/features/cli/migrate/migrate.module.ts +2 -1
  391. package/templates/be/skeleton/src/features/cli/migrate/migrate.options.ts +2 -0
  392. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +6 -0
  393. package/templates/be/skeleton/src/features/cli/seed/seed.module.ts +2 -1
  394. package/templates/be/skeleton/src/features/cli/seed/seed.options.ts +2 -0
  395. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.spec.ts +5 -6
  396. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +56 -4
  397. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +36 -5
  398. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +39 -8
  399. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +14 -8
  400. package/templates/be/skeleton/src/modules/domain/identity/credential.contracts.ts +26 -0
  401. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +119 -0
  402. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +43 -0
  403. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +16 -0
  404. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -1
  405. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +22 -4
  406. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +8 -2
  407. package/templates/be/skeleton/src/modules/domain/identity/index.ts +1 -0
  408. package/templates/be/skeleton/src/modules/platform/database/connection-source.client.spec.ts +11 -4
  409. package/templates/be/skeleton/src/modules/platform/database/database-probe.service.spec.ts +32 -0
  410. package/templates/be/skeleton/src/modules/platform/database/database-probe.service.ts +14 -0
  411. package/templates/be/skeleton/src/modules/platform/database/database.module.ts +7 -1
  412. package/templates/be/skeleton/src/modules/platform/database/index.ts +2 -0
  413. package/templates/be/skeleton/src/modules/platform/database/primary.decorators.ts +8 -0
  414. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +25 -6
  415. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +16 -6
  416. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +130 -0
  417. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +58 -0
  418. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +3 -0
  419. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +16 -4
  420. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -10
  421. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +3 -1
  422. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +2 -0
  423. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +5 -0
  424. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +12 -5
  425. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +13 -8
  426. package/templates/be/skeleton/src/modules/platform/http-security/readiness.contracts.ts +5 -0
  427. package/templates/be/skeleton/src/modules/platform/http-security/readiness.decorators.ts +7 -0
  428. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +86 -0
  429. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +39 -0
  430. package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +139 -131
  431. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +1 -1
  432. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +91 -67
  433. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +5 -4
  434. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +0 -2
  435. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +20 -6
  436. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +16 -4
  437. package/templates/be/skeleton/src/modules/platform/retry/index.ts +3 -0
  438. package/templates/be/skeleton/src/modules/platform/retry/retry.decorators.ts +9 -0
  439. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +7 -0
  440. package/templates/be/skeleton/src/modules/platform/retry/retry.module.ts +19 -0
  441. package/templates/be/skeleton/src/modules/platform/retry/retry.options.ts +2 -0
  442. package/templates/be/skeleton/src/modules/platform/retry/retry.port.ts +11 -0
  443. package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.spec.ts +36 -0
  444. package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.ts +10 -0
  445. package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +54 -0
  446. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +14 -18
  447. package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +4 -0
  448. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +10 -0
  449. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +16 -0
  450. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +2 -0
  451. package/templates/be/skeleton-lite/apps/api/src/compose/system.ts +5 -0
  452. package/templates/be/skeleton-lite/apps/api/src/compose/webhooks.ts +4 -0
  453. package/templates/be/skeleton-lite/apps/api/src/main.ts +2 -3
  454. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +12 -0
  455. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +71 -5
  456. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.ts +29 -2
  457. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +68 -15
  458. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +14 -9
  459. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +9 -2
  460. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +16 -1
  461. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +19 -4
  462. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +5 -1
  463. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -1
  464. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -3
  465. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +17 -5
  466. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +24 -13
  467. package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +5 -0
  468. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +16 -0
  469. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +3 -0
  470. package/templates/be/skeleton-lite/src/modules/platform/http-security/http-security.module.ts +5 -2
  471. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +4 -1
  472. package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
  473. package/templates/be/skeleton-lite/src/modules/platform/logging/logging.log-events.ts +7 -0
  474. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +29 -0
  475. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +16 -0
  476. package/templates/be/table/api/feature.module-definition.ts.tpl +6 -0
  477. package/templates/be/table/api/feature.module.ts.tpl +2 -1
  478. package/templates/be/table/api/feature.options.ts.tpl +2 -0
  479. package/templates/be/table/api/graphql.module-definition.ts.tpl +6 -0
  480. package/templates/be/table/api/graphql.module.ts.tpl +3 -2
  481. package/templates/be/table/api/graphql.options.ts.tpl +2 -0
  482. package/templates/be/table/migration.sql.tpl +1 -0
  483. package/templates/be/upgrade-full/apps/cli/src/app.module.ts +4 -6
  484. package/templates/be/upgrade-full/apps/cli/src/cli.env.ts +7 -0
  485. package/templates/be/upgrade-full/apps/cli/src/compose/cli.ts +5 -0
  486. package/templates/be/upgrade-full/apps/cli/src/compose/platform.ts +9 -0
  487. package/templates/be/upgrade-full/apps/cli/src/main.ts +3 -0
  488. package/templates/be/upgrade-full/src/features/cli/cli.module.ts +3 -2
  489. package/templates/fe/auth/supabase-client.ts.tpl +34 -0
  490. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +11 -0
  491. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/index.tsx +6 -1
  492. package/templates/fe/skeleton/apps/__app__/src/components/leaves/Icon/index.tsx +4 -0
  493. package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/component.tsx +31 -0
  494. package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/index.tsx +24 -0
  495. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -1
  496. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +3 -4
  497. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/index.ts +1 -0
  498. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +15 -0
  499. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +18 -0
  500. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +19 -0
  501. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +7 -1
  502. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/en.json +57 -0
  503. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +6 -0
  504. package/templates/fe/skeleton/apps/landing/src/modules/i18n/messages/en.json +31 -0
  505. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +1 -1
  506. package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -0
  507. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +1 -34
  508. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +7 -1
  509. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +57 -0
  510. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +30 -24
  511. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +1 -1
  512. package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
  513. package/templates/fe/transport/graphql/clients/links/locale.ts +2 -2
  514. package/upgrade/check.mjs +3 -1
  515. package/upgrade/index.mjs +53 -40
@@ -0,0 +1,237 @@
1
+ // stack-ingress-config.mjs - the reader of `environments.<env>.deploy.ingress`: the SHARED host ingress (80/443) of the VPS. The ingress is
2
+ // its own swarm stack (`name`) owning the attachable overlay and the host-mode ports; the app stack consumes it. External (non-swarm, plain
3
+ // docker compose) tenants are reached over the overlay by a UNIQUE alias, or by a host address (`hostAddress`).
4
+ // Pure given the block and a probe of the app tree. `normalizeDeploy` (stack-deploy-config.mjs) hooks it in as `config.ingress`;
5
+ // `starci app stack ingress plan|attach|takeover` (packages/hfs/stack/ingress.mjs) consumes the normalised result.
6
+ import { closedFields, collector, patternField, treeFile } from './stack-config-kit.mjs';
7
+ import { isPlainObject as plain } from '../../engine/plain-object.mjs';
8
+ import { NAME } from './stack-deploy-config.mjs';
9
+
10
+ const DOCKER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/u;
11
+ const REMOTE_PATH = /^\/[A-Za-z0-9._/-]+$/u;
12
+ const DNS_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
13
+ const DNS_HOST = new RegExp(String.raw`^${DNS_LABEL}(?:\.${DNS_LABEL})+$`, 'u');
14
+ const PINNED_IMAGE = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}$)[0-9a-f]{64}$/u;
15
+ const TRAEFIK_ARG = /^--[A-Za-z0-9][A-Za-z0-9.-]*(?:=[^\s]*)?$/u;
16
+ const OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
17
+ const IPV4 = new RegExp(String.raw`^${OCTET}(?:\.${OCTET}){3}$`, 'u');
18
+ const URL_PATH = /^\/[A-Za-z0-9._~%/-]*$/u;
19
+ const REDIRECT_STATUS = [301, 302, 303, 307, 308];
20
+
21
+ const FIELDS = {
22
+ ingress: ['name', 'image', 'args', 'network', 'acmeVolume', 'service', 'legacy', 'upstreams', 'routes', 'middlewares', 'verify', 'drop', 'redirects', 'backup', 'publish', 'takeover'],
23
+ legacy: ['dir', 'dynamicDir', 'container'],
24
+ upstream: ['name', 'hosts', 'container', 'hostAddress', 'port', 'aliases', 'middlewares'],
25
+ verify: ['host', 'path', 'status'],
26
+ redirect: ['host', 'to', 'status'],
27
+ backup: ['dir'],
28
+ publish: ['file', 'supersedes'],
29
+ takeover: ['gateSeconds', 'rehearsalPorts'],
30
+ ports: ['http', 'https'],
31
+ };
32
+
33
+ const closed = (c, at, value, allowed) => closedFields(c, at, value, allowed, 'ingress');
34
+ const matching = patternField;
35
+
36
+ function remotePath(c, at, value) {
37
+ if (typeof value !== 'string' || !REMOTE_PATH.test(value) || value.split('/').includes('..')) c.problem(at, 'must be an absolute path without ..');
38
+ }
39
+
40
+ function port(c, at, value) {
41
+ if (!(Number.isInteger(value) && value >= 1 && value <= 65535)) c.problem(at, 'must be a port from 1 to 65535');
42
+ }
43
+
44
+ function httpStatus(c, at, value) {
45
+ if (!(Number.isInteger(value) && value >= 100 && value <= 599)) c.problem(at, 'must be an HTTP status code');
46
+ }
47
+
48
+ function list(c, at, value, check) {
49
+ if (!Array.isArray(value) || value.length === 0) { c.problem(at, 'must be a non-empty list'); return []; }
50
+ value.forEach((item, index) => check(`${at}[${index}]`, item));
51
+ return value;
52
+ }
53
+
54
+ const names = (c, pattern) => (where, item) => { matching(c, where, item, pattern); };
55
+
56
+ function legacy(c, block) {
57
+ if (!closed(c, 'deploy.ingress.legacy', block, FIELDS.legacy)) return null;
58
+ remotePath(c, 'deploy.ingress.legacy.dir', block.dir);
59
+ remotePath(c, 'deploy.ingress.legacy.dynamicDir', block.dynamicDir);
60
+ matching(c, 'deploy.ingress.legacy.container', block.container, DOCKER_NAME);
61
+ return { dir: block.dir, dynamicDir: block.dynamicDir, container: block.container };
62
+ }
63
+
64
+ /** An upstream is a container on the overlay (alias = the route target) or a host address (`hostAddress`), never both. */
65
+ function target(c, at, entry) {
66
+ if ((entry.container === undefined) === (entry.hostAddress === undefined)) c.problem(at, 'declare exactly one of container and hostAddress');
67
+ if (entry.container !== undefined) matching(c, `${at}.container`, entry.container, DOCKER_NAME);
68
+ if (entry.hostAddress !== undefined) matching(c, `${at}.hostAddress`, entry.hostAddress, IPV4);
69
+ if (entry.hostAddress !== undefined && entry.aliases !== undefined) c.problem(`${at}.aliases`, 'a hostAddress upstream has no alias');
70
+ if (entry.container === undefined) return [];
71
+ return entry.aliases === undefined ? [entry.name] : list(c, `${at}.aliases`, entry.aliases, names(c, DOCKER_NAME));
72
+ }
73
+
74
+ function upstream(c, at, entry) {
75
+ if (!closed(c, at, entry, FIELDS.upstream)) return null;
76
+ matching(c, `${at}.name`, entry.name, NAME);
77
+ port(c, `${at}.port`, entry.port);
78
+ const aliases = target(c, at, entry);
79
+ // an attach-only upstream (a container the tenants' own route files reach, with no host of its own) declares no hosts
80
+ const hosts = entry.hosts === undefined ? [] : list(c, `${at}.hosts`, entry.hosts, names(c, DNS_HOST));
81
+ const middlewareNames = entry.middlewares === undefined ? [] : list(c, `${at}.middlewares`, entry.middlewares, names(c, NAME));
82
+ return {
83
+ name: entry.name, hosts, container: entry.container ?? null, hostAddress: entry.hostAddress ?? null, port: entry.port, aliases, middlewares: middlewareNames,
84
+ };
85
+ }
86
+
87
+ function duplicates(c, label, values, at) {
88
+ const seen = new Set();
89
+ for (const value of values) {
90
+ if (seen.has(value)) c.problem(at, `${label} ${value} is declared more than once`);
91
+ seen.add(value);
92
+ }
93
+ }
94
+
95
+ function upstreams(c, block) {
96
+ if (block === undefined) return [];
97
+ const out = list(c, 'deploy.ingress.upstreams', block, () => {}).map((entry, index) => upstream(c, `deploy.ingress.upstreams[${index}]`, entry)).filter(Boolean);
98
+ duplicates(c, 'upstream name', out.map((entry) => entry.name), 'deploy.ingress.upstreams');
99
+ duplicates(c, 'host', out.flatMap((entry) => entry.hosts), 'deploy.ingress.upstreams');
100
+ duplicates(c, 'alias (aliases must be unique on the shared overlay)', out.flatMap((entry) => entry.aliases), 'deploy.ingress.upstreams');
101
+ return out;
102
+ }
103
+
104
+ function middlewareMap(c, block) {
105
+ if (block === undefined) return {};
106
+ if (!plain(block)) { c.problem('deploy.ingress.middlewares', 'must be a mapping of name to middleware'); return {}; }
107
+ const out = {};
108
+ for (const [name, value] of Object.entries(block)) {
109
+ const at = `deploy.ingress.middlewares.${name}`;
110
+ if (!matching(c, at, name, NAME)) continue;
111
+ if (plain(value)) out[name] = value;
112
+ else c.problem(at, 'must be a mapping');
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function referencedMiddlewares(c, ups, declared, routes) {
118
+ if (routes !== null) return;
119
+ ups.forEach((entry, index) => {
120
+ for (const name of entry.middlewares) {
121
+ if (!Object.hasOwn(declared, name)) c.problem(`deploy.ingress.upstreams[${index}].middlewares`, `middleware ${name} is not declared in deploy.ingress.middlewares`);
122
+ }
123
+ });
124
+ }
125
+
126
+ function verifyList(c, block) {
127
+ if (block === undefined) return [];
128
+ return list(c, 'deploy.ingress.verify', block, () => {}).flatMap((entry, index) => {
129
+ const at = `deploy.ingress.verify[${index}]`;
130
+ if (!closed(c, at, entry, FIELDS.verify)) return [];
131
+ matching(c, `${at}.host`, entry.host, DNS_HOST);
132
+ if (entry.path !== undefined) matching(c, `${at}.path`, entry.path, URL_PATH);
133
+ if (entry.status !== undefined) httpStatus(c, `${at}.status`, entry.status);
134
+ return [{ host: entry.host, path: entry.path ?? '/', status: entry.status ?? null }];
135
+ });
136
+ }
137
+
138
+ function dropList(c, block) {
139
+ return block === undefined ? [] : list(c, 'deploy.ingress.drop', block, names(c, DNS_HOST));
140
+ }
141
+
142
+ function redirects(c, block) {
143
+ if (block === undefined) return [];
144
+ return list(c, 'deploy.ingress.redirects', block, () => {}).flatMap((entry, index) => {
145
+ const at = `deploy.ingress.redirects[${index}]`;
146
+ if (!closed(c, at, entry, FIELDS.redirect)) return [];
147
+ matching(c, `${at}.host`, entry.host, DNS_HOST);
148
+ matching(c, `${at}.to`, entry.to, DNS_HOST);
149
+ if (entry.status !== undefined && !REDIRECT_STATUS.includes(entry.status)) c.problem(`${at}.status`, `must be one of ${REDIRECT_STATUS.join(', ')}`);
150
+ return [{ host: entry.host, to: entry.to, status: entry.status ?? 301 }];
151
+ });
152
+ }
153
+
154
+ function backup(c, block) {
155
+ if (block === undefined) return null;
156
+ if (!closed(c, 'deploy.ingress.backup', block, FIELDS.backup)) return null;
157
+ remotePath(c, 'deploy.ingress.backup.dir', block.dir);
158
+ return { dir: block.dir };
159
+ }
160
+
161
+ const ROUTE_FILE = /^[a-z][a-z0-9-]*.ya?ml$/u;
162
+
163
+ /** Where the app's own routes file lands in the shared directory, and the generic old file names it replaces (both required when declared). */
164
+ function publish(c, block) {
165
+ if (block === undefined) return null;
166
+ const at = 'deploy.ingress.publish';
167
+ if (!closed(c, at, block, FIELDS.publish)) return null;
168
+ matching(c, `${at}.file`, block.file, ROUTE_FILE);
169
+ const supersedes = list(c, `${at}.supersedes`, block.supersedes, (where, name) => {
170
+ if (matching(c, where, name, ROUTE_FILE) && name === block.file) c.problem(where, 'must differ from publish.file');
171
+ });
172
+ duplicates(c, 'superseded file', supersedes, `${at}.supersedes`);
173
+ return { file: block.file, supersedes };
174
+ }
175
+
176
+ const DEFAULT_REHEARSAL_PORTS = { http: 18080, https: 18443 };
177
+
178
+ /** Alternate host ports of the rehearsal: valid ports, never the production 80 and 443, and different from each other. */
179
+ function rehearsalPorts(c, block) {
180
+ const at = 'deploy.ingress.takeover.rehearsalPorts';
181
+ if (block === undefined) return { ...DEFAULT_REHEARSAL_PORTS };
182
+ if (!closed(c, at, block, FIELDS.ports)) return { ...DEFAULT_REHEARSAL_PORTS };
183
+ for (const key of FIELDS.ports) {
184
+ port(c, `${at}.${key}`, block[key]);
185
+ if ([80, 443].includes(block[key])) c.problem(`${at}.${key}`, 'must not be the production port 80 or 443');
186
+ }
187
+ if (block.http === block.https) c.problem(at, 'http and https must differ');
188
+ return { http: block.http, https: block.https };
189
+ }
190
+
191
+ function takeover(c, block) {
192
+ if (block === undefined) return { gateSeconds: 30, rehearsalPorts: { ...DEFAULT_REHEARSAL_PORTS } };
193
+ if (!closed(c, 'deploy.ingress.takeover', block, FIELDS.takeover)) return null;
194
+ const gate = block.gateSeconds ?? 30;
195
+ if (!(Number.isInteger(gate) && gate >= 1 && gate <= 300)) c.problem('deploy.ingress.takeover.gateSeconds', 'must be a whole number of seconds from 1 to 300');
196
+ return { gateSeconds: gate, rehearsalPorts: rehearsalPorts(c, block.rehearsalPorts) };
197
+ }
198
+
199
+ /**
200
+ * Validates and normalises one `deploy.ingress` block (undefined -> { ingress: null, problems: [] }).
201
+ * probe.exists(rel) does a file exist below .starcistacks/<env>/ ?
202
+ * Returns { ingress, problems }; ingress is null when a problem was found.
203
+ */
204
+ export function normalizeIngress(block, { probe }) {
205
+ const c = collector();
206
+ if (block === undefined) return { ingress: null, problems: [] };
207
+ if (!closed(c, 'deploy.ingress', block, FIELDS.ingress)) return { ingress: null, problems: c.problems };
208
+ for (const key of ['name', 'service']) if (block[key] !== undefined) matching(c, `deploy.ingress.${key}`, block[key], NAME);
209
+ if (block.image !== undefined) matching(c, 'deploy.ingress.image', block.image, PINNED_IMAGE);
210
+ const args = block.args === undefined ? null : list(c, 'deploy.ingress.args', block.args, names(c, TRAEFIK_ARG));
211
+ matching(c, 'deploy.ingress.network', block.network, DOCKER_NAME);
212
+ matching(c, 'deploy.ingress.acmeVolume', block.acmeVolume, DOCKER_NAME);
213
+ if (block.routes !== undefined) treeFile(c, 'deploy.ingress.routes', block.routes, probe);
214
+ const ups = upstreams(c, block.upstreams);
215
+ const declared = middlewareMap(c, block.middlewares);
216
+ const routes = block.routes ?? null;
217
+ referencedMiddlewares(c, ups, declared, routes);
218
+ const ingress = {
219
+ name: block.name ?? 'ingress',
220
+ image: block.image ?? null,
221
+ args,
222
+ service: block.service ?? 'ingress',
223
+ network: block.network,
224
+ acmeVolume: block.acmeVolume,
225
+ legacy: legacy(c, block.legacy),
226
+ upstreams: ups,
227
+ routes,
228
+ middlewares: declared,
229
+ verify: verifyList(c, block.verify),
230
+ drop: dropList(c, block.drop),
231
+ redirects: redirects(c, block.redirects),
232
+ backup: backup(c, block.backup),
233
+ publish: publish(c, block.publish),
234
+ takeover: takeover(c, block.takeover),
235
+ };
236
+ return { ingress: c.problems.length === 0 ? ingress : null, problems: c.problems };
237
+ }
@@ -0,0 +1,414 @@
1
+ import { composeEntries } from '../lib/compose-values.mjs';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { lsFiles } from '../api/git/ls-files.mjs';
5
+ import {parseYaml} from '../../engine/yaml.mjs';
6
+ import {slash} from '../lib/path-key.mjs';
7
+ import { isInside, walkFiles } from '../lib/walk.mjs';
8
+ import { composeIncludePaths, swarmInventoryIssues, swarmModelIssues } from '../lib/swarm-stack.mjs';
9
+ import { swarmHardeningIssues } from './rules/stacks-swarm.mjs';
10
+ import { ingressIssues } from './rules/stacks-ingress.mjs';
11
+ import { liteBackingIssues } from './rules/stacks-lite-backing.mjs';
12
+ import { releaseBackupIssues, scheduleIssues } from './rules/stacks-release.mjs';
13
+ import { stacksDeploymentContexts } from './rules/stacks-runtime.mjs';
14
+ import { appEdition, applicationStackFindings, liteCustodyDirectory } from './starcistacks-runtime.mjs';
15
+
16
+ const RESULT='starci/application-stacks-check@1';
17
+ const STACKS_DIR='.starcistacks';
18
+ const MAX_INPUT_BYTES=4*1024*1024;
19
+ const SCHEMA_ID='starci/application-stacks@1';
20
+ const RUNBOOK_COMMANDS=['prepare','doctor','up','status','logs','down','verification'];
21
+ const PLAINTEXT_SECRET_PATTERN=/\.(key|pem|p12|pfx)$/i;
22
+ const inside=(root,target)=>isInside(path.resolve(root),path.resolve(target));
23
+ const object=value=>value&&typeof value==='object'&&!Array.isArray(value);
24
+ const sensitive=/pass(word)?|secret|token|credential|private[_-]?key|api[_-]?key/i;
25
+ const nonempty=value=>typeof value==='string'&&Boolean(value.trim());
26
+ const manifestSchema=parseYaml(fs.readFileSync(new URL('../../modules/schemas/application-stacks.schema.yaml',import.meta.url),'utf8'));
27
+ const HARDENING_CODES=new Set([
28
+ 'STACKS_SECRET_NOT_EXTERNAL','STACKS_SECRET_UNVERSIONED','STACKS_SECRET_ENV_LITERAL','STACKS_PORT_PUBLISHED',
29
+ 'STACKS_SECOND_INGRESS','STACKS_DNS_UNDECLARED','STACKS_HOST_UNROUTED','STACKS_TLS_UNDECLARED',
30
+ 'STACKS_PLACEMENT_MISSING','STACKS_HEALTHCHECK_MISSING','STACKS_UPDATE_POLICY_MISSING','STACKS_REALTIME_SCALED',
31
+ 'STACKS_RELEASE_UNDECLARED','BACKUP_UNDECLARED','BACKUP_PROOF_MISSING','STACKS_MODEL_UNAVAILABLE',
32
+ 'HFS_SCHEDULE_UNDECLARED','STACKS_INGRESS_UNDECLARED','STACKS_INGRESS_UNKNOWN',
33
+ 'STACKS_LITE_SELFHOSTED_DB','STACKS_SIDECAR_VOLUME_UNDECLARED','STACKS_SIDECAR_DB_UNSUPPORTED',
34
+ ]);
35
+
36
+ function regular(file,{root=null}={}){
37
+ try{
38
+ const stat=fs.lstatSync(file);if(!stat.isFile()||stat.isSymbolicLink())return false;
39
+ return !root||inside(root,fs.realpathSync(file));
40
+ }catch{return false;}
41
+ }
42
+ function directory(file){try{return fs.lstatSync(file).isDirectory()&&!fs.lstatSync(file).isSymbolicLink();}catch{return false;}}
43
+ function safeAncestors(root,target){
44
+ if(!inside(root,target))return false;
45
+ let cursor=path.resolve(target),base=path.resolve(root);
46
+ while(cursor!==base){
47
+ if(fs.existsSync(cursor)&&fs.lstatSync(cursor).isSymbolicLink())return false;
48
+ cursor=path.dirname(cursor);
49
+ }
50
+ return true;
51
+ }
52
+ function resolvedPath(base,relative){
53
+ if(!nonempty(relative)||path.isAbsolute(relative)||String(relative).split(/[\\/]/).includes('..'))return null;
54
+ const target=path.resolve(base,String(relative));return inside(base,target)&&safeAncestors(base,target)?target:null;
55
+ }
56
+
57
+ function validateSchema(value){
58
+ const errors=[],isObject=value=>value!==null&&typeof value==='object'&&!Array.isArray(value),resolve=ref=>ref.slice(2).split('/').reduce((node,key)=>node?.[key],manifestSchema);
59
+ const typeOk=(node,type)=>{
60
+ if(type==='object')return isObject(node);
61
+ if(type==='array')return Array.isArray(node);
62
+ if(type==='integer')return Number.isInteger(node);
63
+ if(type)return typeof node===type;
64
+ return true;
65
+ };
66
+ const checkString=(node,shape,at)=>{
67
+ if(shape.minLength&&node.length<shape.minLength)errors.push({path:at,message:'string too short'});
68
+ if(shape.pattern&&!new RegExp(shape.pattern,'u').test(node))errors.push({path:at,message:'pattern mismatch'});
69
+ };
70
+ const checkNumber=(node,shape,at)=>{
71
+ if(shape.minimum!==undefined&&node<shape.minimum)errors.push({path:at,message:'number below minimum'});
72
+ if(shape.maximum!==undefined&&node>shape.maximum)errors.push({path:at,message:'number above maximum'});
73
+ };
74
+ const checkObject=(node,shape,at)=>{
75
+ if(shape.minProperties&&Object.keys(node).length<shape.minProperties)errors.push({path:at,message:'object has too few properties'});
76
+ for(const key of shape.required??[])if(!Object.hasOwn(node,key))errors.push({path:at,message:`missing ${key}`});
77
+ for(const [key,child] of Object.entries(node)){
78
+ if(Object.hasOwn(shape.properties??{},key))check(child,shape.properties[key],`${at}/${key}`);
79
+ else if(shape.additionalProperties===false)errors.push({path:`${at}/${key}`,message:'unknown field'});
80
+ else if(isObject(shape.additionalProperties))check(child,shape.additionalProperties,`${at}/${key}`);
81
+ }
82
+ };
83
+ const checkAlternatives=(node,shape,at,check)=>{
84
+ const matched=shape.anyOf.some((option)=>{
85
+ const start=errors.length;check(node,option,at);
86
+ if(errors.length===start)return true;
87
+ errors.splice(start);return false;
88
+ });
89
+ if(!matched)errors.push({path:at,message:'matches none of the allowed shapes'});
90
+ };
91
+ const checkArray=(node,shape,at,check)=>{
92
+ if(shape.minItems&&node.length<shape.minItems)errors.push({path:at,message:'array too short'});
93
+ node.forEach((item,index)=>shape.items&&check(item,shape.items,`${at}/${index}`));
94
+ };
95
+ const check=(node,shape,at)=>{
96
+ if(shape?.$ref)return check(node,resolve(shape.$ref),at);
97
+ if(Array.isArray(shape?.anyOf)){
98
+ checkAlternatives(node,shape,at,check);
99
+ return;
100
+ }
101
+ const typed=typeOk(node,shape?.type);
102
+ if(!typed){errors.push({path:at,message:`expected ${shape.type}`});return;}
103
+ if(Object.hasOwn(shape??{},'const')&&node!==shape.const)errors.push({path:at,message:'const mismatch'});
104
+ if(Array.isArray(shape?.enum)&&!shape.enum.includes(node))errors.push({path:at,message:'value outside enum'});
105
+ if(typeof node==='string')checkString(node,shape,at);
106
+ if(typeof node==='number')checkNumber(node,shape,at);
107
+ if(Array.isArray(node)){
108
+ checkArray(node,shape,at,check);
109
+ return;
110
+ }
111
+ if(!isObject(node))return;
112
+ checkObject(node,shape,at);
113
+ };check(value,manifestSchema,'$');return errors;
114
+ }
115
+ const unresolved=value=>typeof value==='string'&&/(^|[^$])\$\{/.test(value);
116
+ const serviceUnresolved=(service)=>{
117
+ const build=object(service?.build)?service.build:{context:service?.build};
118
+ const values=[service?.image,build.context,build.dockerfile,...(Array.isArray(service?.ports)?service.ports:[])];
119
+ for(const volume of Array.isArray(service?.volumes)?service.volumes:[])values.push(object(volume)?volume.source:String(volume).split(':')[0]);
120
+ for(const [key,value] of composeEntries(service?.environment, { isMapping: object }))if(!sensitive.test(key))values.push(value);
121
+ return values.some(unresolved);
122
+ };
123
+ function unresolvedCompose(model){
124
+ for(const service of Object.values(object(model?.services)?model.services:{})){
125
+ if(serviceUnresolved(service))return true;
126
+ }
127
+ return Object.values(object(model?.secrets)?model.secrets:{}).some(item=>unresolved(item?.file));
128
+ }
129
+ function sopsEnvelope(file){
130
+ // Recognizes both the YAML/JSON envelope (a "sops" object) and the flattened dotenv envelope SOPS
131
+ // emits for a .env member (sops_lastmodified=, sops_mac=, sops_age__list_0__map_enc=, ...).
132
+ try{
133
+ if(fs.statSync(file).size>MAX_INPUT_BYTES)return false;
134
+ const text=fs.readFileSync(file,'utf8');
135
+ return (/(?:(?:^|\n)sops:|["']sops["']\s*:|(?:^|\n)sops_[a-z0-9_]+=)/.test(text))&&/ENC\[AES256_GCM,data:/.test(text);
136
+ }catch{return false;}
137
+ }
138
+
139
+ function gitTrackedSet(root){
140
+ try{
141
+ const result=lsFiles(['--',STACKS_DIR],{dir:root,timeout:5000});
142
+ if(result.error||result.status!==0||typeof result.stdout!=='string')return null;
143
+ return new Set(result.stdout.split(/\r?\n/).filter(Boolean).map(slash));
144
+ }catch{return null;}
145
+ }
146
+ function composeLike(file){
147
+ try{
148
+ if(fs.statSync(file).size>MAX_INPUT_BYTES)return false;
149
+ const doc=parseYaml(fs.readFileSync(file,'utf8'));
150
+ return object(doc)&&(object(doc.services)||Array.isArray(doc.include));
151
+ }catch{return false;}
152
+ }
153
+ function reachableComposeFiles(envDir,entries){
154
+ const reachable=new Set(),queue=[...entries];
155
+ while(queue.length){
156
+ const relative=queue.shift(),target=resolvedPath(envDir,relative);
157
+ if(!target||reachable.has(target))continue;
158
+ reachable.add(target);
159
+ if(!regular(target,{root:envDir}))continue;
160
+ let doc=null;try{doc=parseYaml(fs.readFileSync(target,'utf8'));}catch{continue;}
161
+ for(const include of composeIncludePaths(doc??{}).paths)
162
+ queue.push(path.isAbsolute(include)?include:slash(path.join(path.relative(envDir,path.dirname(target)),include)));
163
+ }
164
+ return reachable;
165
+ }
166
+ function scanForUndeclaredCompose(root,envDir,reachable,add,at){
167
+ const infra=path.join(envDir,'infra');
168
+ if(!directory(infra))return;
169
+ const stack=[infra];
170
+ while(stack.length){
171
+ const dir=stack.pop();
172
+ let entries=[];try{entries=fs.readdirSync(dir,{withFileTypes:true});}catch{continue;}
173
+ for(const entry of entries){
174
+ const full=path.join(dir,entry.name);
175
+ if(entry.isSymbolicLink())continue;
176
+ if(entry.isDirectory()){stack.push(full);continue;}
177
+ if(!/\.ya?ml$/i.test(entry.name)||reachable.has(full)||!composeLike(full))continue;
178
+ add('STACKS_UNDECLARED_COMPOSE',at,`${slash(path.relative(root,full))} is a Compose-shaped file under infra/ that is not declared or reachable from composeFiles; the composeFiles list must be the complete list for this environment`);
179
+ }
180
+ }
181
+ }
182
+
183
+ const loadInputs=({manifestFile,deploymentModelFile,errors,add})=>{
184
+ const input={manifest:{},model:{}};
185
+ try{if(!errors.some(item=>['manifest-unavailable','input-too-large'].includes(item.code)&&item.path===`${STACKS_DIR}/application-stacks.yaml`))input.manifest=parseYaml(fs.readFileSync(manifestFile,'utf8'));}
186
+ catch{add('manifest-invalid',`${STACKS_DIR}/application-stacks.yaml`,'manifest YAML could not be parsed');}
187
+ try{if(!errors.some(item=>['deployment-model-unavailable','input-too-large'].includes(item.code)&&item.path==='deploymentModelFile')){
188
+ const text=fs.readFileSync(deploymentModelFile,'utf8');try{input.model=JSON.parse(text);}catch{input.model=parseYaml(text);}
189
+ }}catch{add('deployment-model-invalid','deploymentModelFile','rendered deployment JSON or YAML could not be parsed');}
190
+ if(!object(input.manifest))input.manifest={};
191
+ if(!object(input.model))input.model={};
192
+ return input;
193
+ };
194
+ const checkStackEntry=(entry,environmentNames,add,{stacksRoot,lite})=>{
195
+ if(!entry.isDirectory()||entry.isSymbolicLink())return;
196
+ if(entry.name==='tmp'){add('STACKS_SCRATCH_IN_CANONICAL',`${STACKS_DIR}/tmp`,'scratch belongs in the OS temp directory, not a tracked .starcistacks/tmp');return;}
197
+ if(entry.name==='k8s')return;
198
+ if(lite&&liteCustodyDirectory(path.join(stacksRoot,entry.name)))return;
199
+ if(!environmentNames.has(entry.name))add('STACKS_UNDECLARED_ENVIRONMENT',`${STACKS_DIR}/${entry.name}`,`${entry.name} is not an environment the declaration admits; either the declaration admits it or it is not an environment`);
200
+ };
201
+ const checkDeclaredEnvironmentDirs=(stacksRoot,environmentNames,add)=>{
202
+ for(const name of environmentNames)if(!directory(path.join(stacksRoot,name)))add('environment-directory-missing',`${STACKS_DIR}/${name}`,`declared environment ${name} has no directory`);
203
+ };
204
+ const checkStacksTree=({stacksRoot,manifest,environmentNames,add,lite})=>{
205
+ if(!directory(stacksRoot))return;
206
+ let entries=[];try{entries=fs.readdirSync(stacksRoot,{withFileTypes:true});}catch{}
207
+ for(const entry of entries)checkStackEntry(entry,environmentNames,add,{stacksRoot,lite});
208
+ checkDeclaredEnvironmentDirs(stacksRoot,environmentNames,add);
209
+ const k8sDir=path.join(stacksRoot,'k8s'),k8sExists=directory(k8sDir);
210
+ if(manifest.k8s?.status==='supported'&&!k8sExists)add('k8s-directory-missing',`${STACKS_DIR}/k8s`,'k8s.status is supported but .starcistacks/k8s is missing');
211
+ if(manifest.k8s?.status==='deferred'&&k8sExists)add('k8s-directory-unexpected',`${STACKS_DIR}/k8s`,'k8s.status is deferred, so a .starcistacks/k8s directory contradicts the declaration');
212
+ };
213
+ const checkComposeFiles=({spec,environment,envDir,root,add})=>{
214
+ const composeFiles=Array.isArray(spec.composeFiles)?spec.composeFiles:[];
215
+ if(!composeFiles.length)add('compose-files-empty',`environments.${environment}.composeFiles`,'declare at least one environment-relative Compose source file');
216
+ const safeComposeFiles=[];
217
+ for(const [index,relative] of composeFiles.entries()){
218
+ const target=resolvedPath(envDir,relative);
219
+ if(!target||!regular(target,{root:envDir}))add('compose-path-unsafe',`environments.${environment}.composeFiles[${index}]`,'Compose source must be a regular non-symlink file below the environment directory');
220
+ else safeComposeFiles.push(relative);
221
+ }
222
+ if(directory(envDir)){
223
+ if(nonempty(spec.renderedModel))safeComposeFiles.push(spec.renderedModel);
224
+ const reachable=reachableComposeFiles(envDir,safeComposeFiles);
225
+ scanForUndeclaredCompose(root,envDir,reachable,add,`environments.${environment}.composeFiles`);
226
+ }
227
+ };
228
+ const checkRunbook=({spec,environment,envDir,add})=>{
229
+ if(!nonempty(spec.runbook))add('runbook-path-invalid',`environments.${environment}.runbook`,'runbook must name a file inside the environment directory');
230
+ else{
231
+ const runbookFile=resolvedPath(envDir,spec.runbook);
232
+ const oversized=(()=>{try{return fs.statSync(runbookFile).size>MAX_INPUT_BYTES;}catch{return true;}})();
233
+ if(!runbookFile||!regular(runbookFile,{root:envDir})||oversized)
234
+ add('runbook-path-invalid',`environments.${environment}.runbook`,'runbook must resolve to an existing regular non-symlink file below the environment directory');
235
+ else{
236
+ let text='';try{text=fs.readFileSync(runbookFile,'utf8');}catch{}
237
+ for(const command of RUNBOOK_COMMANDS){
238
+ const rowPattern=new RegExp(String.raw`^\s*\|\s*${command}\s*\|`,'im');
239
+ if(!rowPattern.test(text))add('runbook-command-missing',`environments.${environment}.runbook#${command}`,`runbook must document the ${command} command`);
240
+ }
241
+ }
242
+ }
243
+ };
244
+ const checkSecret=(secret,at,{envDir,environment,root,tracked,secretNames,add})=>{
245
+ if(!object(secret))return;
246
+ if(secretNames.has(secret.name))add('secret-duplicate',`${at}.name`,'secret names must be unique');else secretNames.add(secret.name);
247
+ const plain=nonempty(secret.where)?resolvedPath(envDir,secret.where):null;
248
+ const enc=nonempty(secret.where)?resolvedPath(envDir,`${secret.where}.enc`):null;
249
+ if(!enc||!regular(enc,{root:envDir})||!sopsEnvelope(enc))
250
+ add('encrypted-ref-invalid',`${at}.where`,'secret must resolve to <where>.enc as a recognizable SOPS envelope, an existing regular file below the environment directory');
251
+ if(nonempty(secret.where)&&!plain)
252
+ add('materialized-path-invalid',`${at}.where`,'secret plaintext path and its existing ancestors must remain below the environment directory without symlinks');
253
+ if(!String(secret.recipientPolicy??'').trim()||!String(secret.keyCustody??'').trim())
254
+ add('secret-policy-missing',at,'secret recipient and key custody policies are required');
255
+ if(tracked&&plain&&tracked.has(slash(path.relative(root,plain))))
256
+ add('STACKS_PLAINTEXT_SECRET',`${at}.where`,'a decrypted secret member must never be a tracked file; only its .enc member is committed');
257
+ };
258
+ const checkSecrets=({spec,environment,envDir,root,add})=>{
259
+ const declaredSecrets=Array.isArray(spec.secrets)?spec.secrets:[],secretNames=new Set();
260
+ const tracked=directory(envDir)?gitTrackedSet(root):null;
261
+ for(const [index,secret] of declaredSecrets.entries())
262
+ checkSecret(secret,`environments.${environment}.secrets[${index}]`,{envDir,environment,root,tracked,secretNames,add});
263
+ if(tracked){
264
+ const envPrefix=`${slash(path.relative(root,envDir))}/`;
265
+ for(const relTracked of tracked){
266
+ if(!relTracked.startsWith(envPrefix))continue;
267
+ const base=path.basename(relTracked);
268
+ if(PLAINTEXT_SECRET_PATTERN.test(base)||base==='id_rsa')
269
+ add('STACKS_PLAINTEXT_SECRET',relTracked,'a tracked file below the environment directory matches a plaintext secret pattern; only <name>.enc may be committed');
270
+ }
271
+ }
272
+ };
273
+ const checkEnvironmentSpec=({spec,environment,envDir,ids,root,add})=>{
274
+ if(spec.status!=='supported')add('environment-unsupported',`environments.${environment}`,'environment must be explicitly supported');
275
+ if(!['docker-compose','docker-swarm'].includes(spec.runtime))add('runtime-invalid',`environments.${environment}.runtime`,'runtime must be docker-compose or docker-swarm');
276
+ checkComposeFiles({spec,environment,envDir,root,add});
277
+ const bindings=object(spec.components)?spec.components:{};
278
+ for(const id of Object.keys(bindings))if(!ids.has(id))
279
+ add('binding-component-unknown',`environments.${environment}.components.${id}`,'environment binds a component id that is absent from the top-level inventory');
280
+ checkRunbook({spec,environment,envDir,add});
281
+ checkSecrets({spec,environment,envDir,root,add});
282
+ };
283
+ const checkServiceIds=(services,ids,add)=>{
284
+ for(const service of Object.keys(services))if(!ids.has(service))
285
+ add('compose-service-unclassified',`services.${service}`,'rendered service name is not a declared component id');
286
+ };
287
+ const checkServiceEnvironment=(services,add)=>{
288
+ for(const [service,definition] of Object.entries(services))for(const [key] of composeEntries(definition?.environment, { isMapping: object }))
289
+ if(sensitive.test(key)&&!/_FILE$/i.test(key))add('plaintext-sensitive-environment',`services.${service}.environment.${key}`,'sensitive configuration is present as a direct environment value; value redacted');
290
+ };
291
+ const checkModel=({model,ids,spec,manifest,lite,add})=>{
292
+ const services=object(model.services)?model.services:{};
293
+ checkServiceIds(services,ids,add);
294
+ if(unresolvedCompose(model))add('compose-placeholder-unresolved','composeModel','rendered Compose still contains an unresolved interpolation placeholder in a host-resolved field');
295
+ checkServiceEnvironment(services,add);
296
+ if(object(spec)&&spec.runtime==='docker-swarm')for(const issue of swarmModelIssues({model,components:manifest.components,bindings:spec.components,lite}))
297
+ add('HFS_STACKS_SHAPE',issue.at,issue.message);
298
+ };
299
+
300
+ const checkInputPaths=({environment,stacksRoot,manifestFile,deploymentModelFile,add})=>{
301
+ if(!nonempty(environment))add('environment-invalid','environment','environment must name a declared environment');
302
+ if(!regular(manifestFile,{root:stacksRoot}))add('manifest-unavailable',`${STACKS_DIR}/application-stacks.yaml`,`manifest must be a regular file inside ${STACKS_DIR}`);
303
+ if(!regular(deploymentModelFile))add('deployment-model-unavailable','deploymentModelFile','rendered deployment evidence must be a regular, non-symlink file');
304
+ for(const [file,at] of [[manifestFile,`${STACKS_DIR}/application-stacks.yaml`],[deploymentModelFile,'deploymentModelFile']])
305
+ try{if(fs.statSync(file).size>MAX_INPUT_BYTES)add('input-too-large',at,'input exceeds the 4 MiB static-check limit');}catch{}
306
+ };
307
+ const componentIdsOf=(manifest,add)=>{
308
+ const catalog=object(manifest.components)?manifest.components:{},ids=new Set(Object.keys(catalog));
309
+ if(!ids.size)add('components-empty','components','declare at least one applicable application component');
310
+ for(const id of ids)if(!/^[a-z][a-z0-9-]*$/.test(id))add('component-invalid',`components.${id}`,'component ids must be lowercase kebab-case');
311
+ return ids;
312
+ };
313
+ const environmentMapOf=(manifest,add)=>{
314
+ const environments=object(manifest.environments)?manifest.environments:{},environmentNames=new Set(Object.keys(environments));
315
+ if(!environmentNames.size)add('environments-empty','environments','declare at least one supported environment');
316
+ return {environments,environmentNames};
317
+ };
318
+ const checkKubernetesDeclaration=(manifest,add)=>{
319
+ if(!['deferred','supported'].includes(manifest.k8s?.status)||!String(manifest.k8s?.reason??'').trim())
320
+ add('k8s-status-invalid','k8s','Kubernetes must be explicitly deferred or supported with a reason');
321
+ };
322
+ const checkSelectedEnvironment=({environment,environmentNames,environments,stacksRoot,ids,root,model,manifest,lite,add})=>{
323
+ if(nonempty(environment)&&!environmentNames.has(String(environment)))
324
+ add('environment-unknown','environment',`environment must be one the declaration admits: ${[...environmentNames].join(', ')}`);
325
+ const spec=environments[String(environment)],envDir=path.join(stacksRoot,String(environment??''));
326
+ if(object(spec))checkEnvironmentSpec({spec,environment,envDir,ids,root,add});
327
+ checkModel({model,ids,spec,manifest,lite,add});
328
+ };
329
+ const checkStacksManifest=(manifest,model,{stacksRoot,environment,root,repo,add})=>{
330
+ for(const issue of validateSchema(manifest))add('schema-shape-invalid',issue.path,issue.message);
331
+ if(manifest.schema!==SCHEMA_ID)add('schema-invalid','schema',`expected ${SCHEMA_ID}`);
332
+ const ids=componentIdsOf(manifest,add),{environments,environmentNames}=environmentMapOf(manifest,add);
333
+ const lite=repo.edition==='lite';
334
+ checkStacksTree({stacksRoot,manifest,environmentNames,add,lite});
335
+ checkKubernetesDeclaration(manifest,add);
336
+ for(const issue of swarmInventoryIssues({components:manifest.components,lite}))add('HFS_STACKS_SHAPE',issue.at,issue.message);
337
+ checkSelectedEnvironment({environment,environmentNames,environments,stacksRoot,ids,root,model,manifest,lite,add});
338
+ };
339
+
340
+ function deploymentAvailable(model,errors){
341
+ const invalid=errors.some(item=>item.path==='deploymentModelFile');
342
+ return !invalid&&object(model.services)&&Object.keys(model.services).length>0&&model.include===undefined;
343
+ }
344
+
345
+ function diskEvidenceFiles(root){
346
+ const excluded=new Set(['node_modules','.git','.claude','.aws','.codex']);
347
+ return walkFiles(root,{ignoreReadErrors:true,exclude:(name,_file,entry)=>entry.isSymbolicLink()||excluded.has(name)})
348
+ .map(file=>slash(path.relative(root,file)));
349
+ }
350
+
351
+ function checkSwarmHardening({manifest,model,environment,root,files,repo,errors,add}){
352
+ const spec=manifest.environments?.[environment];
353
+ if(spec?.runtime!=='docker-swarm')return;
354
+ if(!deploymentAvailable(model,errors)){
355
+ add('STACKS_MODEL_UNAVAILABLE',`environments.${environment}`,'a rendered deployment model with nonempty services is required to judge Swarm safety');
356
+ return;
357
+ }
358
+ const context={doc:manifest,environment,spec,model,repoRoot:root,files,repo};
359
+ for(const finder of [swarmHardeningIssues,ingressIssues,liteBackingIssues,releaseBackupIssues,scheduleIssues])
360
+ for(const issue of finder(context))add(issue.code,issue.at,issue.message);
361
+ }
362
+
363
+ export function checkApplicationStacks({repoRoot,environment,deploymentModelFile,files,repo}={}){
364
+ const root=path.resolve(String(repoRoot??'')),stacksRoot=path.join(root,STACKS_DIR),manifestFile=path.join(stacksRoot,'application-stacks.yaml'),errors=[];
365
+ const declaration=repo??{edition:appEdition(root)};
366
+ const add=(code,at,message)=>errors.push({code,path:at,message});
367
+ checkInputPaths({environment,stacksRoot,manifestFile,deploymentModelFile,add});
368
+ const {manifest,model}=loadInputs({manifestFile,deploymentModelFile,errors,add});
369
+ checkStacksManifest(manifest,model,{stacksRoot,environment,root,repo:declaration,add});
370
+ checkSwarmHardening({manifest,model,environment,root,files:files??diskEvidenceFiles(root),repo:declaration,errors,add});
371
+ for(const item of applicationStackFindings(root))add(item.code,item.path,item.message);
372
+
373
+ return {schema:RESULT,ok:errors.length===0,environment,manifest:manifestFile,deploymentModel:path.resolve(String(deploymentModelFile??'')),errors,
374
+ limitations:['static conformance only; Docker was not invoked, host application processes were not started, and remote APIs were not called','the deployment model must be rendered by Docker Compose or Docker Stack tooling; provenance is supplied by the caller and is not independently authenticated','component classification, runbook completeness and secret custody are author assertions checked for consistency and structure, not independently discovered facts','SOPS envelope recognition is structural, not cryptographic verification or decryption','tracked-plaintext detection uses git ls-files when the repository is reachable and is skipped, not assumed clean, when it is not','runbook prepare, doctor and verification commands are declared and documented but not executed']};
375
+ }
376
+
377
+ const declarationFindingCodes=new Set(['STACKS_RELEASE_UNDECLARED','BACKUP_UNDECLARED','BACKUP_PROOF_MISSING','HFS_SCHEDULE_UNDECLARED']);
378
+
379
+ function trackedGateFinding(entry,error){
380
+ let file=entry.file;
381
+ const code=HARDENING_CODES.has(error.code)?error.code:'HFS_STACKS_SHAPE';
382
+ const modelPath=/^(?:services(?:\.|$)|secrets(?:\.|$)|composeModel$|deploymentModelFile$)/.test(error.path);
383
+ if(declarationFindingCodes.has(error.code)||!modelPath&&code==='HFS_STACKS_SHAPE')file=`${STACKS_DIR}/application-stacks.yaml`;
384
+ if(error.code==='HFS_SCHEDULE_UNDECLARED'){
385
+ const source=error.path.replace(/:\d+$/,'');
386
+ if(/\.(?:sql|[cm]?[jt]s)$/.test(source))file=source;
387
+ }
388
+ return {code,level:'error',path:file,message:`${file}: ${error.path}: ${error.message}`};
389
+ }
390
+
391
+ const findingKey=finding=>`${finding.code}\n${slash(finding.path)}\n${finding.message}`;
392
+
393
+ function trackedGateErrors(entry,context,files){
394
+ if(entry.unavailable)return [{code:'STACKS_MODEL_UNAVAILABLE',level:'error',path:entry.file,
395
+ message:`${entry.file}: environments.${entry.environment}: a tracked rendered deployment model is required to judge Swarm safety`}];
396
+ const result=checkApplicationStacks({repoRoot:context.repoRoot,environment:entry.environment,
397
+ deploymentModelFile:path.resolve(context.repoRoot,entry.file),files,repo:context.repo});
398
+ return result.errors.map(error=>trackedGateFinding(entry,error));
399
+ }
400
+
401
+ /** Run the public gate on the app's tracked rendered Swarm evidence, without duplicating finder results. */
402
+ export function trackedStacksGateFindings(context={}){
403
+ const {deployments,files}=stacksDeploymentContexts(context),findings=[];
404
+ const existing=context.existingFindings??[],seen=new Set(existing.map(findingKey));
405
+ for(const entry of deployments){
406
+ for(const finding of trackedGateErrors(entry,context,files)){
407
+ if(context.inScope&&!context.inScope(finding.path))continue;
408
+ const key=findingKey(finding);
409
+ if(seen.has(key))continue;
410
+ seen.add(key);findings.push(finding);
411
+ }
412
+ }
413
+ return findings;
414
+ }