@starci/hfs 4.2.17 → 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 (564) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +7 -0
  3. package/db/migrate.mjs +361 -0
  4. package/db/post-cutover.mjs +136 -0
  5. package/emit/db-types.mjs +10 -1
  6. package/lint/run.mjs +1 -1
  7. package/package.json +4 -1
  8. package/release/gate.mjs +298 -0
  9. package/release/index.mjs +103 -0
  10. package/release/push.mjs +110 -0
  11. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  12. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  13. package/runtime/knowledge/hfs/rules.yaml +3437 -160
  14. package/runtime/knowledge/hfs/slots.yaml +430 -92
  15. package/runtime/knowledge/patterns/be/api.yaml +92 -2
  16. package/runtime/knowledge/patterns/be/cli.yaml +37 -9
  17. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  18. package/runtime/knowledge/patterns/be/jobs.yaml +20 -2
  19. package/runtime/knowledge/patterns/be/projections.yaml +4 -2
  20. package/runtime/knowledge/patterns/be/queues.yaml +11 -3
  21. package/runtime/knowledge/patterns/be/reactors.yaml +20 -1
  22. package/runtime/knowledge/patterns/be/realtime.yaml +25 -3
  23. package/runtime/knowledge/patterns/be/saga.yaml +20 -3
  24. package/runtime/knowledge/patterns/be/webhooks.yaml +210 -36
  25. package/runtime/knowledge/patterns/fe/folder.yaml +4 -0
  26. package/runtime/knowledge/sonar-gate.yaml +1 -1
  27. package/runtime/modules/kernel/failure-codes.yaml +3128 -47
  28. package/runtime/modules/models/runtimes.yaml +1 -1
  29. package/runtime/modules/schemas/application-stacks.schema.yaml +663 -0
  30. package/runtime/packages/cli/src/catalog.generated.mjs +14104 -0
  31. package/runtime/packages/cli/src/validate-args.mjs +213 -0
  32. package/runtime/scripts/api/git/add.mjs +5 -0
  33. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  34. package/runtime/scripts/api/git/init.mjs +7 -0
  35. package/runtime/scripts/api/git/lib.mjs +3 -0
  36. package/runtime/scripts/api/git/tag.mjs +5 -0
  37. package/runtime/scripts/api/node/lib.mjs +20 -0
  38. package/runtime/scripts/api/node/run-node.mjs +6 -0
  39. package/runtime/scripts/api/sops/lib.mjs +41 -0
  40. package/runtime/scripts/api/sops/resolve-sops.mjs +7 -0
  41. package/runtime/scripts/api/sops/seal.mjs +1 -1
  42. package/runtime/scripts/api/sops/selected-age-recipient.mjs +7 -0
  43. package/runtime/scripts/api/supabase/database-dump.mjs +5 -0
  44. package/runtime/scripts/api/supabase/database-query.mjs +5 -0
  45. package/runtime/scripts/api/supabase/lib.mjs +44 -0
  46. package/runtime/scripts/api/supabase/migration-repair.mjs +5 -0
  47. package/runtime/scripts/hfs/app-wiring.mjs +201 -0
  48. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +103 -0
  49. package/runtime/scripts/hfs/architecture/backend.mjs +22 -10
  50. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  51. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  52. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  53. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  54. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  55. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  56. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  57. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  58. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  59. package/runtime/scripts/hfs/architecture/dead-exports.mjs +23 -50
  60. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  61. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  62. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  63. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  64. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  65. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  66. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  67. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  68. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +49 -25
  69. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  70. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  71. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  72. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  73. package/runtime/scripts/hfs/architecture/register-once.mjs +15 -7
  74. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  75. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  76. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  77. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +214 -17
  78. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  79. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  80. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  81. package/runtime/scripts/hfs/architecture/tiers.mjs +11 -4
  82. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  83. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +9 -13
  84. package/runtime/scripts/hfs/check-results.mjs +28 -0
  85. package/runtime/scripts/hfs/check.mjs +63 -72
  86. package/runtime/scripts/hfs/coverage-from.mjs +70 -0
  87. package/runtime/scripts/hfs/db-baseline-verb.mjs +276 -0
  88. package/runtime/scripts/hfs/db-plan-verb.mjs +254 -0
  89. package/runtime/scripts/hfs/db-restamp-verb.mjs +60 -0
  90. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  91. package/runtime/scripts/hfs/doc-commands.mjs +367 -0
  92. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  93. package/runtime/scripts/hfs/example-secrets.mjs +158 -0
  94. package/runtime/scripts/hfs/manifest-shape.mjs +91 -6
  95. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  96. package/runtime/scripts/hfs/refusal-codes.mjs +6 -0
  97. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  98. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  99. package/runtime/scripts/hfs/rules/a15-findings.mjs +88 -0
  100. package/runtime/scripts/hfs/rules/app-env-required.mjs +20 -0
  101. package/runtime/scripts/hfs/rules/app-root.mjs +2 -2
  102. package/runtime/scripts/hfs/rules/assets.mjs +79 -0
  103. package/runtime/scripts/hfs/rules/boot-proof.mjs +16 -0
  104. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +59 -0
  105. package/runtime/scripts/hfs/rules/contract.mjs +66 -5
  106. package/runtime/scripts/hfs/rules/coverage-below.mjs +230 -0
  107. package/runtime/scripts/hfs/rules/database-access.mjs +343 -0
  108. package/runtime/scripts/hfs/rules/database-baseline.mjs +142 -0
  109. package/runtime/scripts/hfs/rules/database-config.mjs +250 -2
  110. package/runtime/scripts/hfs/rules/database-constants.mjs +9 -0
  111. package/runtime/scripts/hfs/rules/database-migrations.mjs +317 -37
  112. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +119 -9
  113. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +176 -0
  114. package/runtime/scripts/hfs/rules/database-queues.mjs +361 -0
  115. package/runtime/scripts/hfs/rules/database-sql.mjs +91 -9
  116. package/runtime/scripts/hfs/rules/database.mjs +11 -2
  117. package/runtime/scripts/hfs/rules/deploy-scan.mjs +192 -0
  118. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +219 -0
  119. package/runtime/scripts/hfs/rules/doc-fresh.mjs +286 -0
  120. package/runtime/scripts/hfs/rules/docker-health.mjs +249 -0
  121. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  122. package/runtime/scripts/hfs/rules/edition.mjs +11 -10
  123. package/runtime/scripts/hfs/rules/env-parity.mjs +373 -0
  124. package/runtime/scripts/hfs/rules/env-value-shape.mjs +113 -0
  125. package/runtime/scripts/hfs/rules/event-bus.mjs +3 -2
  126. package/runtime/scripts/hfs/rules/event-tables.mjs +100 -0
  127. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +115 -15
  128. package/runtime/scripts/hfs/rules/fe-stack.mjs +57 -0
  129. package/runtime/scripts/hfs/rules/feature-files.mjs +28 -0
  130. package/runtime/scripts/hfs/rules/frontend-tree.mjs +54 -35
  131. package/runtime/scripts/hfs/rules/integration-specs.mjs +3 -2
  132. package/runtime/scripts/hfs/rules/lexicon.mjs +62 -0
  133. package/runtime/scripts/hfs/rules/lite-proof-lane.mjs +89 -0
  134. package/runtime/scripts/hfs/rules/monorepo.mjs +14 -1
  135. package/runtime/scripts/hfs/rules/pipeline.mjs +8 -1
  136. package/runtime/scripts/hfs/rules/proof-commands.mjs +2 -0
  137. package/runtime/scripts/hfs/rules/release-provenance.mjs +211 -0
  138. package/runtime/scripts/hfs/rules/saga-delivery.mjs +66 -0
  139. package/runtime/scripts/hfs/rules/saga.mjs +8 -16
  140. package/runtime/scripts/hfs/rules/script-hygiene.mjs +151 -0
  141. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +65 -0
  142. package/runtime/scripts/hfs/rules/secrets.mjs +26 -0
  143. package/runtime/scripts/hfs/rules/services.mjs +4 -4
  144. package/runtime/scripts/hfs/rules/stack-tooling.mjs +93 -0
  145. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +76 -0
  146. package/runtime/scripts/hfs/rules/stacks-ingress.mjs +87 -0
  147. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +111 -0
  148. package/runtime/scripts/hfs/rules/stacks-release.mjs +366 -0
  149. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +168 -0
  150. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +417 -0
  151. package/runtime/scripts/hfs/rules/stacks.mjs +13 -8
  152. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  153. package/runtime/scripts/hfs/rules/supply-chain.mjs +167 -0
  154. package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +101 -0
  155. package/runtime/scripts/hfs/rules/unused-symbol-ledger.mjs +77 -0
  156. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +165 -0
  157. package/runtime/scripts/hfs/rules/unused-symbol-values.mjs +103 -0
  158. package/runtime/scripts/hfs/rules/unused-symbol.mjs +320 -0
  159. package/runtime/scripts/hfs/runtime-rules/source-ast.mjs +185 -0
  160. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +19 -0
  161. package/runtime/scripts/hfs/sealed-members.mjs +101 -0
  162. package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +200 -0
  163. package/runtime/scripts/hfs/secret-document.mjs +97 -0
  164. package/runtime/scripts/hfs/secret-edit.mjs +170 -0
  165. package/runtime/scripts/hfs/secret-errors.mjs +20 -0
  166. package/runtime/scripts/hfs/secret-files.mjs +124 -0
  167. package/runtime/scripts/hfs/secret-init.mjs +300 -0
  168. package/runtime/scripts/hfs/secret-output.mjs +100 -0
  169. package/runtime/scripts/hfs/secret-sops.mjs +130 -0
  170. package/runtime/scripts/hfs/secret-store.mjs +163 -0
  171. package/runtime/scripts/hfs/secret.mjs +162 -193
  172. package/runtime/scripts/hfs/slot-imports.mjs +18 -3
  173. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  174. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  175. package/runtime/scripts/hfs/slots.mjs +1 -0
  176. package/runtime/scripts/hfs/stack-config-kit.mjs +34 -0
  177. package/runtime/scripts/hfs/stack-deploy-config.mjs +313 -0
  178. package/runtime/scripts/hfs/stack-ingress-config.mjs +237 -0
  179. package/runtime/scripts/hfs/stacks-gate.mjs +414 -0
  180. package/runtime/scripts/hfs/starcistacks-runtime.mjs +61 -0
  181. package/runtime/scripts/hfs/tree-findings.mjs +22 -0
  182. package/runtime/scripts/lib/command-tokens.mjs +126 -0
  183. package/runtime/scripts/lib/compose-values.mjs +11 -0
  184. package/runtime/scripts/lib/deployment-findings.mjs +7 -0
  185. package/runtime/scripts/lib/json-clone.mjs +61 -0
  186. package/runtime/scripts/lib/list.mjs +4 -0
  187. package/runtime/scripts/lib/roots.mjs +59 -0
  188. package/runtime/scripts/lib/runtime-example.mjs +13 -0
  189. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  190. package/runtime/scripts/lib/sql-ast.mjs +32 -0
  191. package/runtime/scripts/lib/stack-services.mjs +1 -1
  192. package/runtime/scripts/lib/swarm-stack.mjs +126 -0
  193. package/runtime/scripts/lib/trim-end.mjs +8 -0
  194. package/runtime/scripts/lib/ts-analysis.mjs +23 -0
  195. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  196. package/runtime/scripts/lib/workflow-events.mjs +8 -0
  197. package/scaffold/add-app.mjs +77 -59
  198. package/scaffold/add-cli-lite.mjs +24 -17
  199. package/scaffold/add-compose.mjs +79 -0
  200. package/scaffold/add-database-lite.mjs +14 -0
  201. package/scaffold/add-fe-overlay.mjs +122 -0
  202. package/scaffold/add-fe-page.mjs +269 -0
  203. package/scaffold/add-inbox-lite.mjs +87 -124
  204. package/scaffold/add-plan.mjs +183 -0
  205. package/scaffold/add-table.mjs +132 -70
  206. package/scaffold/add.mjs +186 -204
  207. package/scaffold/app.mjs +128 -43
  208. package/scaffold/edition-gate.mjs +3 -3
  209. package/scaffold/image.mjs +2 -2
  210. package/scaffold/lite-exports.mjs +2 -1
  211. package/scaffold/service.mjs +2 -2
  212. package/src/main.mjs +238 -119
  213. package/stack/age-install.mjs +37 -0
  214. package/stack/backup.mjs +100 -0
  215. package/stack/build-args.mjs +42 -0
  216. package/stack/build.mjs +173 -0
  217. package/stack/cli.mjs +130 -0
  218. package/stack/config.mjs +57 -0
  219. package/stack/context.mjs +57 -0
  220. package/stack/converge.mjs +107 -0
  221. package/stack/deploy.mjs +301 -0
  222. package/stack/digest.mjs +16 -0
  223. package/stack/docker-transport.mjs +53 -0
  224. package/stack/edge-alias.mjs +118 -0
  225. package/stack/errors.mjs +21 -0
  226. package/stack/image-scan.mjs +152 -0
  227. package/stack/ingress-create.mjs +114 -0
  228. package/stack/ingress-publish.mjs +273 -0
  229. package/stack/ingress.mjs +706 -0
  230. package/stack/jobs.mjs +51 -0
  231. package/stack/live-restore.mjs +177 -0
  232. package/stack/prepare.mjs +266 -0
  233. package/stack/private-file.mjs +22 -0
  234. package/stack/provision.mjs +90 -0
  235. package/stack/prune.mjs +46 -0
  236. package/stack/release-store.mjs +87 -0
  237. package/stack/remote.mjs +205 -0
  238. package/stack/render.mjs +149 -0
  239. package/stack/retire.mjs +47 -0
  240. package/stack/sealed.mjs +149 -0
  241. package/stack/task.mjs +42 -0
  242. package/stack/verify.mjs +99 -0
  243. package/sync/ci-commands.mjs +13 -0
  244. package/sync/index.mjs +127 -64
  245. package/sync/stack-images.mjs +127 -0
  246. package/templates/app/ci-workflows/github/workflows/ci.yml +5 -1
  247. package/templates/app/ci-workflows/github/workflows/images.yml +15 -3
  248. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +148 -0
  249. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +7 -3
  250. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +38 -6
  251. package/templates/app/ci-workflows-lite/github/workflows/images.yml +14 -2
  252. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +148 -0
  253. package/templates/app/codegen-graphql.mjs.partial +13 -2
  254. package/templates/app/codegen-lite/scripts/codegen.mjs +6 -1
  255. package/templates/app/codegen-prelude.mjs.partial +8 -0
  256. package/templates/app/gitignore +4 -0
  257. package/templates/app/gitignore-lite +4 -0
  258. package/templates/app/hooks/husky/pre-commit +7 -12
  259. package/templates/app/package-scripts/package.json +1 -0
  260. package/templates/app/package-scripts/supabase.json +7 -0
  261. package/templates/app/package-scripts-lite/package.json +1 -7
  262. package/templates/app/skeleton/.editorconfig +1 -0
  263. package/templates/app/skeleton/.gitattributes +7 -1
  264. package/templates/app/skeleton/.github/dependabot.yml +17 -0
  265. package/templates/app/skeleton/.nvmrc +1 -1
  266. package/templates/app/skeleton/.sops.yaml +1 -1
  267. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +134 -1
  268. package/templates/app/skeleton/.starcistacks/dev/README.md +16 -0
  269. package/templates/app/skeleton/.starcistacks/dev/environment.json +5 -0
  270. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +71 -0
  271. package/templates/app/skeleton/.starcistacks/dev/runtime/env/KEYS.md +14 -0
  272. package/templates/app/skeleton/.starcistacks/production/README.md +37 -0
  273. package/templates/app/skeleton/.starcistacks/production/environment.json +5 -0
  274. package/templates/app/skeleton/.starcistacks/production/infra/swarm/Caddyfile +15 -0
  275. package/templates/app/skeleton/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
  276. package/templates/app/skeleton/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
  277. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
  278. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/images.lock.yaml +9 -0
  279. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
  280. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +142 -0
  281. package/templates/app/skeleton/.starcistacks/production/runtime/env/KEYS.md +14 -0
  282. package/templates/app/skeleton/README.md +6 -6
  283. package/templates/app/skeleton/scripts/codegen.mjs +1 -2
  284. package/templates/app/skeleton-lite/.editorconfig +16 -0
  285. package/templates/app/skeleton-lite/.gitattributes +8 -0
  286. package/templates/app/skeleton-lite/.github/dependabot.yml +17 -0
  287. package/templates/app/skeleton-lite/.nvmrc +1 -0
  288. package/templates/app/skeleton-lite/.sops.yaml +3 -2
  289. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +193 -0
  290. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +20 -0
  291. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +2 -1
  292. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +16 -0
  293. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +11 -0
  294. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +7 -0
  295. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +6 -0
  296. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +8 -0
  297. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +6 -0
  298. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +120 -0
  299. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -0
  300. package/templates/app/skeleton-lite/.starcistacks/production/README.md +38 -0
  301. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +6 -0
  302. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/Caddyfile +11 -0
  303. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
  304. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
  305. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
  306. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/images.lock.yaml +8 -0
  307. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
  308. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +121 -0
  309. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +21 -0
  310. package/templates/app/skeleton-lite/.starciwork/brand/index.yaml +12 -0
  311. package/templates/app/skeleton-lite/.starciwork/workspace.yaml +9 -0
  312. package/templates/app/skeleton-lite/README.md +8 -4
  313. package/templates/app/skeleton-lite/supabase/config.toml +8 -1
  314. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +15 -6
  315. package/templates/app/starciwork.gitignore-lite +6 -0
  316. package/templates/app/upgrade-full/.starcistacks/dev/README.md +13 -7
  317. package/templates/be/image/worker/Dockerfile +1 -1
  318. package/templates/be/inbox/database-client.spec.ts.tpl +22 -28
  319. package/templates/be/inbox/database-client.ts.tpl +14 -28
  320. package/templates/be/inbox/delivery-recorded.event.ts.tpl +36 -0
  321. package/templates/be/inbox/event-index.ts.tpl +1 -0
  322. package/templates/be/inbox/events.json.tpl +10 -0
  323. package/templates/be/inbox/migration.sql.tpl +1 -0
  324. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +42 -0
  325. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +9 -0
  326. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +6 -0
  327. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +19 -0
  328. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +2 -0
  329. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +16 -0
  330. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +45 -0
  331. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +15 -0
  332. package/templates/be/inbox/platform/event-bus/index.ts.tpl +5 -0
  333. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +5 -0
  334. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +9 -0
  335. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +6 -0
  336. package/templates/be/inbox/platform/inbox/inbox.module.ts.tpl +19 -0
  337. package/templates/be/inbox/platform/inbox/inbox.options.ts.tpl +2 -0
  338. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +10 -0
  339. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +41 -0
  340. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +19 -0
  341. package/templates/be/inbox/platform/inbox/index.ts.tpl +3 -0
  342. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +7 -0
  343. package/templates/be/inbox/platform/migration.sql.tpl +48 -0
  344. package/templates/be/inbox/service.spec.ts.tpl +49 -20
  345. package/templates/be/inbox/service.ts.tpl +27 -16
  346. package/templates/be/inbox/sql.ts.tpl +2 -2
  347. package/templates/be/inbox/webhook-door.spec.ts.tpl +3 -3
  348. package/templates/be/inbox/webhook-door.ts.tpl +4 -3
  349. package/templates/be/patterns/api/feature.module-definition.ts.tpl +6 -0
  350. package/templates/be/patterns/api/feature.module.ts.tpl +2 -1
  351. package/templates/be/patterns/api/feature.options.ts.tpl +2 -0
  352. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +6 -0
  353. package/templates/be/patterns/api/graphql.module.ts.tpl +3 -2
  354. package/templates/be/patterns/api/graphql.options.ts.tpl +2 -0
  355. package/templates/be/patterns/cli/group.module-definition.ts.tpl +6 -0
  356. package/templates/be/patterns/cli/group.module.ts.tpl +2 -1
  357. package/templates/be/patterns/cli/group.options.ts.tpl +2 -0
  358. package/templates/be/patterns/event-bus/platform/errors/event-bus.error.ts.tpl +0 -3
  359. package/templates/be/patterns/event-bus/platform/messages/event-bus.messages.ts.tpl +0 -2
  360. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +6 -0
  361. package/templates/be/patterns/jobs/job.module.ts.tpl +2 -1
  362. package/templates/be/patterns/jobs/job.options.ts.tpl +2 -0
  363. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +6 -0
  364. package/templates/be/patterns/jobs/job.queue.module.ts.tpl +6 -3
  365. package/templates/be/patterns/jobs/job.queue.options.ts.tpl +2 -0
  366. package/templates/be/patterns/jobs/platform/fenced.processor.spec.ts.tpl +3 -1
  367. package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +25 -9
  368. package/templates/be/patterns/queues/platform/queue.config.ts.tpl +1 -1
  369. package/templates/be/patterns/queues/platform/queue.index.ts.tpl +1 -1
  370. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +2 -2
  371. package/templates/be/patterns/queues/platform/queue.module.ts.tpl +11 -0
  372. package/templates/be/patterns/queues/platform/queue.options.ts.tpl +10 -0
  373. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +6 -0
  374. package/templates/be/patterns/queues/queue.module.ts.tpl +2 -1
  375. package/templates/be/patterns/queues/queue.options.ts.tpl +2 -0
  376. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +6 -0
  377. package/templates/be/patterns/reactors/message.module.ts.tpl +6 -3
  378. package/templates/be/patterns/reactors/message.options.ts.tpl +2 -0
  379. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +6 -0
  380. package/templates/be/patterns/reactors/reactor.module.ts.tpl +2 -1
  381. package/templates/be/patterns/reactors/reactor.options.ts.tpl +2 -0
  382. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +188 -0
  383. package/templates/be/patterns/realtime/channel.gateway.ts.tpl +22 -0
  384. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +6 -0
  385. package/templates/be/patterns/realtime/channel.graphql.module.ts.tpl +2 -1
  386. package/templates/be/patterns/realtime/channel.graphql.options.ts.tpl +2 -0
  387. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +6 -0
  388. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +110 -0
  389. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +7 -0
  390. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +6 -0
  391. package/templates/be/patterns/saga/saga.message.module.ts.tpl +6 -3
  392. package/templates/be/patterns/saga/saga.message.options.ts.tpl +2 -0
  393. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +6 -0
  394. package/templates/be/patterns/saga/saga.module.ts.tpl +2 -1
  395. package/templates/be/patterns/saga/saga.options.ts.tpl +2 -0
  396. package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +3 -3
  397. package/templates/be/patterns/webhooks/webhook.door.ts.tpl +3 -3
  398. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +6 -0
  399. package/templates/be/patterns/webhooks/webhook.http.module.ts.tpl +2 -1
  400. package/templates/be/patterns/webhooks/webhook.http.options.ts.tpl +2 -0
  401. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +80 -26
  402. package/templates/be/platform/graphql/localize-errors.mapper.ts +2 -5
  403. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +21 -0
  404. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +1 -1
  405. package/templates/be/skeleton/apps/__app__/src/app.module.ts +15 -45
  406. package/templates/be/skeleton/apps/__app__/src/compose/api.ts +4 -0
  407. package/templates/be/skeleton/apps/__app__/src/compose/domain.ts +11 -0
  408. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +44 -0
  409. package/templates/be/skeleton/apps/__app__/src/compose/system.ts +5 -0
  410. package/templates/be/skeleton/apps/__app__/src/compose/webhooks.ts +4 -0
  411. package/templates/be/skeleton/apps/__app__/src/main.ts +18 -10
  412. package/templates/be/skeleton/apps/cli/src/app.module.ts +3 -13
  413. package/templates/be/skeleton/apps/cli/src/cli.env.ts +13 -0
  414. package/templates/be/skeleton/apps/cli/src/cli.options.ts +7 -17
  415. package/templates/be/skeleton/apps/cli/src/compose/cli.ts +5 -0
  416. package/templates/be/skeleton/apps/cli/src/compose/platform.ts +24 -0
  417. package/templates/be/skeleton/apps/cli/src/main.ts +3 -2
  418. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.contracts.ts +2 -0
  419. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.handler.ts +22 -0
  420. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.query.ts +11 -0
  421. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +6 -0
  422. package/templates/be/skeleton/src/features/api/system-health/system-health.module.ts +5 -3
  423. package/templates/be/skeleton/src/features/api/system-health/system-health.options.ts +2 -0
  424. package/templates/be/skeleton/src/features/api/system-health/transport/http/ready.controller.ts +19 -0
  425. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +6 -0
  426. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module.ts +8 -3
  427. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.options.ts +2 -0
  428. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +6 -0
  429. package/templates/be/skeleton/src/features/cli/cli.module.ts +3 -2
  430. package/templates/be/skeleton/src/features/cli/cli.options.ts +2 -0
  431. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +6 -0
  432. package/templates/be/skeleton/src/features/cli/migrate/migrate.module.ts +2 -1
  433. package/templates/be/skeleton/src/features/cli/migrate/migrate.options.ts +2 -0
  434. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +6 -0
  435. package/templates/be/skeleton/src/features/cli/seed/seed.module.ts +2 -1
  436. package/templates/be/skeleton/src/features/cli/seed/seed.options.ts +2 -0
  437. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.spec.ts +5 -6
  438. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +56 -4
  439. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +36 -5
  440. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +39 -8
  441. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +14 -8
  442. package/templates/be/skeleton/src/modules/domain/identity/credential.contracts.ts +26 -0
  443. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +119 -0
  444. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +43 -0
  445. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +16 -0
  446. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -1
  447. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +22 -4
  448. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +8 -2
  449. package/templates/be/skeleton/src/modules/domain/identity/index.ts +1 -0
  450. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +6 -6
  451. package/templates/be/skeleton/src/modules/platform/database/connection-source.client.spec.ts +11 -4
  452. package/templates/be/skeleton/src/modules/platform/database/database-probe.service.spec.ts +32 -0
  453. package/templates/be/skeleton/src/modules/platform/database/database-probe.service.ts +14 -0
  454. package/templates/be/skeleton/src/modules/platform/database/database.module.ts +7 -1
  455. package/templates/be/skeleton/src/modules/platform/database/index.ts +2 -0
  456. package/templates/be/skeleton/src/modules/platform/database/primary.decorators.ts +8 -0
  457. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +25 -6
  458. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +16 -6
  459. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +130 -0
  460. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +58 -0
  461. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +3 -0
  462. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +16 -4
  463. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -10
  464. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +3 -1
  465. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +2 -0
  466. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +5 -0
  467. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +12 -5
  468. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +13 -8
  469. package/templates/be/skeleton/src/modules/platform/http-security/readiness.contracts.ts +5 -0
  470. package/templates/be/skeleton/src/modules/platform/http-security/readiness.decorators.ts +7 -0
  471. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +86 -0
  472. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +39 -0
  473. package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +139 -131
  474. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +1 -1
  475. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +91 -67
  476. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +5 -4
  477. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +0 -2
  478. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +20 -6
  479. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +16 -4
  480. package/templates/be/skeleton/src/modules/platform/retry/index.ts +3 -0
  481. package/templates/be/skeleton/src/modules/platform/retry/retry.decorators.ts +9 -0
  482. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +7 -0
  483. package/templates/be/skeleton/src/modules/platform/retry/retry.module.ts +19 -0
  484. package/templates/be/skeleton/src/modules/platform/retry/retry.options.ts +2 -0
  485. package/templates/be/skeleton/src/modules/platform/retry/retry.port.ts +11 -0
  486. package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.spec.ts +36 -0
  487. package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.ts +10 -0
  488. package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +54 -0
  489. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +25 -0
  490. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +1 -1
  491. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +18 -58
  492. package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +4 -0
  493. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +10 -0
  494. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +16 -0
  495. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +47 -0
  496. package/templates/be/skeleton-lite/apps/api/src/compose/system.ts +5 -0
  497. package/templates/be/skeleton-lite/apps/api/src/compose/webhooks.ts +4 -0
  498. package/templates/be/skeleton-lite/apps/api/src/main.ts +5 -13
  499. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +17 -0
  500. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +12 -0
  501. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +71 -5
  502. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.ts +29 -2
  503. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +68 -15
  504. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +14 -9
  505. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +9 -2
  506. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +16 -1
  507. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +19 -4
  508. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +5 -1
  509. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -1
  510. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -3
  511. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +17 -5
  512. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +24 -13
  513. package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +5 -0
  514. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +16 -0
  515. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +3 -0
  516. package/templates/be/skeleton-lite/src/modules/platform/http-security/http-security.module.ts +5 -2
  517. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +4 -1
  518. package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
  519. package/templates/be/skeleton-lite/src/modules/platform/logging/logging.log-events.ts +7 -0
  520. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +29 -0
  521. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +16 -0
  522. package/templates/be/table/api/feature.module-definition.ts.tpl +6 -0
  523. package/templates/be/table/api/feature.module.ts.tpl +2 -1
  524. package/templates/be/table/api/feature.options.ts.tpl +2 -0
  525. package/templates/be/table/api/graphql.module-definition.ts.tpl +6 -0
  526. package/templates/be/table/api/graphql.module.ts.tpl +3 -2
  527. package/templates/be/table/api/graphql.options.ts.tpl +2 -0
  528. package/templates/be/table/migration.sql.tpl +1 -0
  529. package/templates/be/upgrade-full/apps/cli/src/app.module.ts +4 -6
  530. package/templates/be/upgrade-full/apps/cli/src/cli.env.ts +7 -0
  531. package/templates/be/upgrade-full/apps/cli/src/compose/cli.ts +5 -0
  532. package/templates/be/upgrade-full/apps/cli/src/compose/platform.ts +9 -0
  533. package/templates/be/upgrade-full/apps/cli/src/main.ts +3 -0
  534. package/templates/be/upgrade-full/src/features/cli/cli.module.ts +3 -2
  535. package/templates/fe/auth/supabase-client.ts.tpl +34 -0
  536. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +11 -0
  537. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/index.tsx +6 -1
  538. package/templates/fe/skeleton/apps/__app__/src/components/leaves/Icon/index.tsx +4 -0
  539. package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/component.tsx +31 -0
  540. package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/index.tsx +24 -0
  541. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -1
  542. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +3 -4
  543. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/index.ts +1 -0
  544. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +15 -0
  545. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +18 -0
  546. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +19 -0
  547. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +7 -1
  548. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/en.json +57 -0
  549. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +6 -0
  550. package/templates/fe/skeleton/apps/landing/src/modules/i18n/messages/en.json +31 -0
  551. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +1 -1
  552. package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -0
  553. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +1 -34
  554. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +7 -1
  555. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +57 -0
  556. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +30 -24
  557. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +1 -1
  558. package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
  559. package/templates/fe/transport/graphql/clients/links/locale.ts +2 -2
  560. package/upgrade/check.mjs +3 -1
  561. package/upgrade/file-state.mjs +68 -0
  562. package/upgrade/index.mjs +58 -94
  563. package/runtime/scripts/api/git/log.mjs +0 -5
  564. package/templates/app/skeleton-lite/.starciwork/shell/index.yaml +0 -55
@@ -0,0 +1,152 @@
1
+ // image-scan.mjs - the guard before a PUBLIC image is pushed: layers of a public package are public, so the built image is scanned for secret material
2
+ // and the push is refused on any hit. Three sources, all read from the local docker daemon (no registry, no credentials):
3
+ // history the build instructions (docker history): secret patterns, and build arguments that are not on the public build-args list
4
+ // environment the baked ENV: secret patterns, and non-empty values of variables named like a secret
5
+ // filesystem the exported container filesystem (a tar, read by header): files named like secret material outside the library directories, and the
6
+ // content of small text files for secret patterns
7
+ // A hit names the rule and the place (a path, a history line, a variable NAME), never the matched text.
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { firstLine, lines as splitLines, tool } from './context.mjs';
12
+
13
+ const SECRET_PATTERNS = [
14
+ ['a private key block', /-----BEGIN (?:[A-Z]+ )*PRIVATE KEY-----/u],
15
+ ['an age secret key', /AGE-SECRET-KEY-1[0-9A-Z]{20,}/u],
16
+ ['a sealed secret envelope', /ENC\[AES256_GCM,/u],
17
+ ['sops metadata', /"sops"\s*:\s*\{|^sops:\s*$/mu],
18
+ ['a GitHub token', /\bgh[pousr]_[A-Za-z0-9]{30,}\b/u],
19
+ ['an AWS access key id', /\bAKIA[0-9A-Z]{16}\b/u],
20
+ ['a Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/u],
21
+ ['a live API key', /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}/u],
22
+ ];
23
+ const SECRET_NAME = /(?:SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|API_?KEY|ACCESS_?KEY)/iu;
24
+ const SECRET_FILE = /(?:^|\/)(?:\.env(?!\.(?:example|sample|template)$)[^/]*|secrets?\.env|id_(?:rsa|dsa|ecdsa|ed25519)|[^/]+\.(?:pem|key|p12|pfx|jks|enc)|\.npmrc|\.netrc|\.git-credentials|\.sops\.yaml|keys\.txt)$/u;
25
+ /** Directories whose files are libraries or the base system, not the app: test certificates and sample keys live there by design. */
26
+ const LIBRARY_DIR = /^(?:usr\/(?:share|lib|local\/lib|include)|etc\/(?:ssl|ca-certificates|pki)|lib|var\/lib|opt\/yarn|root\/\.(?:npm|cache)|home\/[^/]+\/\.(?:npm|cache)|(?:[^/]+\/)*node_modules)(?:\/|$)/u;
27
+ const TEXT_FILE = /\.(?:[cm]?js|ts|json|ya?ml|env|txt|conf|cfg|ini|sh|toml|properties|md)$/u;
28
+ const MAX_TEXT_BYTES = 128 * 1024;
29
+ const BLOCK = 512;
30
+ const BUILD_ARG_PREFIX = /^\|\d+ ((?:[A-Za-z_][A-Za-z0-9_]*=\S*\s?)+)/u;
31
+ const ARG_INSTRUCTION = /\bARG\s+([A-Za-z_][A-Za-z0-9_]*)/gu;
32
+
33
+ const cleanName = (name) => name.replace(/^\.?\//u, '').replace(/\/$/u, '');
34
+ const secretPatternIn = (text) => SECRET_PATTERNS.find(([, pattern]) => pattern.test(text))?.[0] ?? null;
35
+
36
+ /** Reads one NUL-terminated field of a tar header. */
37
+ const field = (header, from, length) => header.toString('utf8', from, from + length).replace(/\0.*$/su, '');
38
+
39
+ function sizeOf(header) {
40
+ if (header[124] & 0x80) {
41
+ let value = 0;
42
+ for (let index = 125; index < 136; index += 1) value = (value * 256) + header[index];
43
+ return value;
44
+ }
45
+ return Number.parseInt(field(header, 124, 12).trim() || '0', 8);
46
+ }
47
+
48
+ const paxPath = (body) => body.toString('utf8').split('\n').map((record) => /^\d+ path=(.*)$/u.exec(record)).find(Boolean)?.[1] ?? null;
49
+
50
+ /**
51
+ * Walks a tar file by header: calls visit({ name, type, size, read }) for every entry (`read()` returns the content, called only for files worth
52
+ * reading). Handles ustar prefixes, GNU long names (L) and PAX paths (x). Content is never loaded unless the visitor asks for it.
53
+ */
54
+ export function walkTar(file, visit) {
55
+ const fd = fs.openSync(file, 'r');
56
+ try {
57
+ const header = Buffer.alloc(BLOCK);
58
+ let position = 0;
59
+ let pending = null;
60
+ for (;;) {
61
+ if (fs.readSync(fd, header, 0, BLOCK, position) < BLOCK || header.every((byte) => byte === 0)) return;
62
+ const size = sizeOf(header);
63
+ const type = String.fromCharCode(header[156] || 48);
64
+ const prefix = field(header, 345, 155);
65
+ const own = field(header, 0, 100);
66
+ const contentAt = position + BLOCK;
67
+ const read = () => { const body = Buffer.alloc(size); fs.readSync(fd, body, 0, size, contentAt); return body; };
68
+ if (type === 'L' || type === 'x') {
69
+ const body = read();
70
+ pending = type === 'L' ? body.toString('utf8').replace(/\0.*$/su, '') : paxPath(body) ?? pending;
71
+ } else {
72
+ visit({ name: cleanName(pending ?? (prefix ? `${prefix}/${own}` : own)), type, size, read });
73
+ pending = null;
74
+ }
75
+ position = contentAt + (Math.ceil(size / BLOCK) * BLOCK);
76
+ }
77
+ } finally {
78
+ fs.closeSync(fd);
79
+ }
80
+ }
81
+
82
+ function historyHits(historyText, allowedArgs) {
83
+ const hits = [];
84
+ splitLines(historyText).forEach((line, index) => {
85
+ const found = secretPatternIn(line);
86
+ if (found) hits.push({ kind: 'history', where: `history line ${index + 1}`, why: found });
87
+ const names = [...(BUILD_ARG_PREFIX.exec(line)?.[1].trim().split(/\s+/u).map((pair) => pair.split('=')[0]) ?? []), ...[...line.matchAll(ARG_INSTRUCTION)].map((match) => match[1])];
88
+ for (const name of new Set(names)) if (!allowedArgs.has(name)) hits.push({ kind: 'build-arg', where: `history line ${index + 1}`, why: `build argument ${name} is not on the public build-args list` });
89
+ });
90
+ return hits;
91
+ }
92
+
93
+ function environmentHits(envJson) {
94
+ let values = [];
95
+ try { values = JSON.parse(envJson); } catch { values = []; }
96
+ return (Array.isArray(values) ? values : []).flatMap((entry) => {
97
+ const at = String(entry).indexOf('=');
98
+ const name = String(entry).slice(0, at);
99
+ const value = String(entry).slice(at + 1);
100
+ const found = secretPatternIn(value);
101
+ if (found) return [{ kind: 'env', where: `environment variable ${name}`, why: found }];
102
+ return SECRET_NAME.test(name) && value !== '' ? [{ kind: 'env', where: `environment variable ${name}`, why: 'a variable named like a secret holds a value' }] : [];
103
+ });
104
+ }
105
+
106
+ function fileHits(tarFile) {
107
+ const hits = [];
108
+ walkTar(tarFile, ({ name, type, size, read }) => {
109
+ if (!['0', '7'].includes(type) || LIBRARY_DIR.test(name)) return;
110
+ if (SECRET_FILE.test(name)) hits.push({ kind: 'file', where: name, why: 'a file named like secret material' });
111
+ else if (TEXT_FILE.test(name) && size > 0 && size <= MAX_TEXT_BYTES) {
112
+ const found = secretPatternIn(read().toString('utf8'));
113
+ if (found) hits.push({ kind: 'content', where: name, why: found });
114
+ }
115
+ });
116
+ return hits;
117
+ }
118
+
119
+ /** The filesystem of the image as a tar file (create, export to a file, remove), or an error line. */
120
+ function exportFilesystem(ctx, ref, directory) {
121
+ const created = tool(ctx, 'docker', ['create', ref]);
122
+ const id = firstLine(created.stdout);
123
+ if (created.status !== 0 || id === '') return { error: `docker create failed: ${firstLine(created.stderr)}` };
124
+ const tarFile = path.join(directory, 'filesystem.tar');
125
+ const exported = tool(ctx, 'docker', ['export', '-o', tarFile, id]);
126
+ tool(ctx, 'docker', ['rm', '-f', id]);
127
+ return exported.status === 0 ? { tarFile } : { error: `docker export failed: ${firstLine(exported.stderr)}` };
128
+ }
129
+
130
+ /**
131
+ * Scans a built image. `allowedArgs` = the names of the public build arguments that were passed. Returns { ok, hits, errors }:
132
+ * errors are scan steps that could not run (the push is refused too: an unscanned public image is not pushed).
133
+ */
134
+ export function scanImage(ctx, ref, { allowedArgs }) {
135
+ const hits = [];
136
+ const errors = [];
137
+ const history = tool(ctx, 'docker', ['history', '--no-trunc', '--format', '{{.CreatedBy}}', ref]);
138
+ if (history.status === 0) hits.push(...historyHits(history.stdout, new Set(allowedArgs)));
139
+ else errors.push(`docker history failed: ${firstLine(history.stderr)}`);
140
+ const environment = tool(ctx, 'docker', ['inspect', '--format', '{{json .Config.Env}}', ref]);
141
+ if (environment.status === 0) hits.push(...environmentHits(environment.stdout));
142
+ else errors.push(`docker inspect failed: ${firstLine(environment.stderr)}`);
143
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'stack-scan-'));
144
+ try {
145
+ const exported = exportFilesystem(ctx, ref, directory);
146
+ if (exported.error) errors.push(exported.error);
147
+ else hits.push(...fileHits(exported.tarFile));
148
+ } finally {
149
+ fs.rmSync(directory, { recursive: true, force: true });
150
+ }
151
+ return { ok: hits.length === 0 && errors.length === 0, hits, errors };
152
+ }
@@ -0,0 +1,114 @@
1
+ // ingress-create.mjs - `starci app stack ingress create`: deploys the SHARED ingress as its own swarm stack with ZERO replicas, so the service exists
2
+ // (rehearse and takeover need it) while nothing binds a port and the live edge is untouched. The stack is rendered from the deploy block:
3
+ // the pinned image, the whole legacy dynamic directory bind-mounted read-only, the persisted certificates volume, the attachable overlay (external),
4
+ // host-mode 80/443 (they bind only when a task starts) and no ACME email. The static arguments are the declared `ingress.args` or, byte for byte, the
5
+ // ones the live edge container runs with (read-only inspect), so the new proxy behaves exactly like the old one. It refuses to start a task.
6
+ import { refusal } from './errors.mjs';
7
+ import { remoteDocker, shellQuote } from './remote.mjs';
8
+
9
+ const ACME_EMAIL = /acme\.email=(.+)$/iu;
10
+ const SERVICE_NETWORK = 'edge';
11
+
12
+ const parse = (text) => { try { return JSON.parse(text); } catch { return null; } };
13
+ const asArray = (value) => (Array.isArray(value) ? value : []);
14
+
15
+ /** The static arguments and the two mount targets of the live edge container (inspect formats read Args and Mounts only, never the environment). */
16
+ function readLegacy(run, ingress) {
17
+ const container = ingress.legacy.container;
18
+ const args = parse(remoteDocker(run, ['inspect', '--format', '{{json .Args}}', container]).stdout);
19
+ const mounts = asArray(parse(remoteDocker(run, ['inspect', '--format', '{{json .Mounts}}', container]).stdout));
20
+ const target = (match) => mounts.find(match)?.Destination;
21
+ return {
22
+ args: ingress.args ?? asArray(args).filter((arg) => typeof arg === 'string'),
23
+ dynamicTarget: target((mount) => mount.Source === ingress.legacy.dynamicDir),
24
+ acmeTarget: target((mount) => mount.Name === ingress.acmeVolume),
25
+ };
26
+ }
27
+
28
+ /** The stack model (compose spec as JSON, which is valid YAML). */
29
+ export function stackModel(ingress, { args, dynamicTarget, acmeTarget }) {
30
+ const ports = [80, 443].map((port) => ({ target: port, published: port, protocol: 'tcp', mode: 'host' }));
31
+ return {
32
+ services: {
33
+ [ingress.service]: {
34
+ image: ingress.image,
35
+ command: args,
36
+ ports,
37
+ volumes: [
38
+ { type: 'bind', source: ingress.legacy.dynamicDir, target: dynamicTarget, read_only: true },
39
+ { type: 'volume', source: 'acme', target: acmeTarget },
40
+ ],
41
+ networks: [SERVICE_NETWORK],
42
+ logging: { driver: 'json-file', options: { 'max-size': '10m', 'max-file': '5' } },
43
+ deploy: {
44
+ replicas: 0,
45
+ placement: { constraints: ['node.role == manager'] },
46
+ update_config: { order: 'stop-first', failure_action: 'rollback' },
47
+ restart_policy: { condition: 'any' },
48
+ },
49
+ },
50
+ },
51
+ networks: { [SERVICE_NETWORK]: { external: true, name: ingress.network } },
52
+ volumes: { acme: { external: true, name: ingress.acmeVolume } },
53
+ };
54
+ }
55
+
56
+ /** The guard that no task can start from this model. */
57
+ export function refuseRunning(model) {
58
+ for (const [name, service] of Object.entries(model.services)) {
59
+ if (service.deploy?.replicas !== 0) throw refusal('STACK_INGRESS_WOULD_START', `the ingress service ${name} would start a task; create deploys it with replicas 0 only`);
60
+ }
61
+ }
62
+
63
+ function emailProblem(args) {
64
+ const configured = args.map((arg) => ACME_EMAIL.exec(arg)).find((match) => match !== null);
65
+ return configured ? 'the arguments set an ACME email; a new email registers a new ACME account: leave it unset at takeover' : null;
66
+ }
67
+
68
+ const swarmActive = (run) => remoteDocker(run, ['info', '--format', '{{.Swarm.LocalNodeState}} {{.Swarm.ControlAvailable}}']).stdout.trim() === 'active true';
69
+ const overlayAttachable = (run, network) => remoteDocker(run, ['network', 'inspect', '--format', '{{.Driver}} {{.Attachable}}', network]).stdout.trim() === 'overlay true';
70
+ const volumePresent = (run, volume) => remoteDocker(run, ['volume', 'inspect', volume]).status === 0;
71
+
72
+ /** Replica state of the service: { exists, desired }. */
73
+ function serviceState(run, name) {
74
+ const listed = remoteDocker(run, ['service', 'ls', '--filter', `name=${name}`, '--format', '{{.Name}} {{.Replicas}}']);
75
+ const row = String(listed.stdout).split('\n').map((line) => line.trim().split(' ')).find(([found]) => found === name);
76
+ if (listed.status !== 0 || !row) return { exists: false, desired: 0 };
77
+ return { exists: true, desired: Number(String(row[1]).split('/')[1]) };
78
+ }
79
+
80
+ function checks(run, ingress, legacy, service) {
81
+ const directory = run(`test -d ${shellQuote(ingress.legacy.dynamicDir)}`).status === 0;
82
+ return [
83
+ ['an image is declared (deploy.ingress.image, pinned by digest)', typeof ingress.image === 'string', 'declare deploy.ingress.image'],
84
+ ['static arguments known (declared, or read from the live edge)', legacy.args.length > 0, `cannot read the arguments of ${ingress.legacy.container}`],
85
+ ['no ACME email configured', emailProblem(legacy.args) === null, emailProblem(legacy.args)],
86
+ ['dynamic directory and certificates mounts found on the live edge', typeof legacy.dynamicTarget === 'string' && typeof legacy.acmeTarget === 'string', 'the live edge container does not mount the dynamic directory and the certificates volume'],
87
+ ['swarm active (manager)', swarmActive(run), 'this host is not an active swarm manager'],
88
+ [`network ${ingress.network} is an attachable overlay`, overlayAttachable(run, ingress.network), 'run "starci app stack ingress attach --execute" first'],
89
+ [`certificates volume ${ingress.acmeVolume} present`, volumePresent(run, ingress.acmeVolume), 'the persisted certificates volume is missing'],
90
+ [`dynamic directory ${ingress.legacy.dynamicDir} present`, directory, 'the dynamic directory is missing'],
91
+ [`service ${ingress.name}_${ingress.service} is not running a task`, service.desired === 0, `it already runs ${service.desired} replica(s): use takeover or scale it by hand`],
92
+ ].map(([name, ok, detail]) => ({ name, ok, detail }));
93
+ }
94
+
95
+ /** `ingress create`: dry run unless execute. Returns { ok, lines, data }. */
96
+ export function createIngress(run, ingress, execute) {
97
+ const legacy = readLegacy(run, ingress);
98
+ const stack = `${ingress.name}_${ingress.service}`;
99
+ const results = checks(run, ingress, legacy, serviceState(run, stack));
100
+ const lines = results.map((item) => (item.ok ? `precondition ok: ${item.name}` : `precondition FAILED: ${item.name} (${item.detail})`));
101
+ if (results.some((item) => !item.ok)) return { ok: false, lines: [...lines, 'create refused: nothing was changed'], data: { steps: [] } };
102
+ const model = stackModel(ingress, legacy);
103
+ refuseRunning(model);
104
+ const summary = `stack ${ingress.name}: service ${ingress.service} image ${ingress.image}, ${legacy.args.length} static argument(s), host-mode 80/443, replicas 0`;
105
+ if (!execute) return { ok: true, lines: [...lines, `dry run: would deploy ${summary}`], data: { steps: [] } };
106
+ const deployed = run(['docker', ...['stack', 'deploy', '--detach=true', '-c', '-', ingress.name].map(shellQuote)].join(' '), { input: JSON.stringify(model) });
107
+ if (deployed.status !== 0) return { ok: false, lines: [...lines, `docker stack deploy failed: ${String(deployed.stderr).trim().split('\n')[0]}`], data: { steps: [{ step: 'deploy', ok: false }] } };
108
+ const tasks = remoteDocker(run, ['service', 'ps', stack, '--filter', 'desired-state=running', '-q']).stdout.trim();
109
+ if (tasks !== '') {
110
+ remoteDocker(run, ['service', 'scale', `${stack}=0`]);
111
+ return { ok: false, lines: [...lines, 'REFUSED: a task of the ingress service started; it was scaled back to 0'], data: { steps: [{ step: 'deploy', ok: true }, { step: 'no-task', ok: false }] } };
112
+ }
113
+ return { ok: true, lines: [...lines, `deployed ${summary}; no task runs and no port is bound`], data: { steps: [{ step: 'deploy', ok: true }, { step: 'no-task', ok: true }] } };
114
+ }
@@ -0,0 +1,273 @@
1
+ // ingress-publish.mjs - `starci app stack ingress publish`: puts the app's own routes file (deploy.ingress.routes) into the dynamic directory the
2
+ // shared ingress serves, under `publish.file`, and retires the old generic file(s) listed in `publish.supersedes`. The directory is shared with
3
+ // tenants that write their own files: those files are NEVER touched; the only remote writes and removals are `publish.file`, its `.tmp` upload
4
+ // name and the superseded names.
5
+ // Preconditions (all or nothing): the routes file parses, no router or service name clashes with a tenant file, the ingress service runs, the old
6
+ // edge does not, every service of the app stack is healthy, the age recipient and the backup directory exist.
7
+ // --execute: baseline of every host -> age backup of each superseded file AND of the current publish.file when it exists -> upload as <file>.tmp (ignored by the file provider because of its
8
+ // extension) -> ONE remote command swaps it in and removes the superseded files -> every host is verified (declared status exact, otherwise the
9
+ // baseline status class; a host that did not answer before is new: it must answer 2xx-4xx over a certificate valid for its name, whatever the
10
+ // default certificate was; certificates of hosts that answered before unchanged) -> any failure puts the superseded files AND the previous
11
+ // publish.file back from memory (publish.file is removed only when there was none) and re-verifies the baseline (RESTORED ok / RESTORE FAILED (manual: ...)).
12
+ // Route files may carry header values or tokens: they are held in memory only and NEVER printed; lines carry names, hosts and statuses.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
16
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
17
+ import { firstLine } from './context.mjs';
18
+ import { refusal } from './errors.mjs';
19
+ import { remoteDocker, shellQuote } from './remote.mjs';
20
+
21
+ const FILE_MARK = /^#@@FILE (.+)$/mu;
22
+ const HEALTHY = /^([1-9]\d*)\/\1$/u;
23
+ const VERIFY_ATTEMPTS = 6;
24
+ const VERIFY_MS = 2000;
25
+
26
+ const asMap = (value) => (plain(value) ? value : {});
27
+ const classOf = (code) => Math.floor(code / 100);
28
+ const answers = (code) => code >= 200 && code < 500;
29
+ const label = (target) => `${target.host}${target.path}`;
30
+ const result = (ok, lines, steps, extra = {}) => ({ ok, lines, data: { ok, steps, ...extra } });
31
+ const quote = (value) => shellQuote(value);
32
+ const shScript = (script) => `sh -c ${quote(script)}`;
33
+
34
+ /** The app's own routes file: { text, routers, services } or { error }. */
35
+ function readOwn(config, ingress) {
36
+ let text = '';
37
+ let doc = null;
38
+ try {
39
+ text = fs.readFileSync(path.join(config.envDir, ingress.routes), 'utf8');
40
+ doc = parseYaml(text);
41
+ } catch {
42
+ return { error: `${ingress.routes} cannot be read as YAML` };
43
+ }
44
+ const http = doc?.http;
45
+ if (!plain(http) || !plain(http.routers) || !plain(http.services)) return { error: `${ingress.routes} needs http.routers and http.services mappings` };
46
+ return { text, routers: Object.keys(http.routers), services: Object.keys(http.services) };
47
+ }
48
+
49
+ /** The route files of the live directory: Map name -> text (listing only; the contents are never printed). */
50
+ function readFiles(run, kit, dir) {
51
+ const listed = run(kit.liveCommand(dir));
52
+ if (listed.status !== 0) return { error: `cannot read ${dir} on the manager: ${firstLine(listed.stderr)}` };
53
+ const parts = String(listed.stdout).split(FILE_MARK);
54
+ const files = new Map();
55
+ for (let index = 1; index < parts.length; index += 2) files.set(parts[index], parts[index + 1]);
56
+ return { files };
57
+ }
58
+
59
+ /** Router and service names of the other files that the own file would collide with (superseded files and the target are ignored). */
60
+ function clashes(files, own, ignore) {
61
+ const found = [];
62
+ const unreadable = [];
63
+ for (const [name, body] of files) {
64
+ if (ignore.has(name)) continue;
65
+ let doc = null;
66
+ try { doc = parseYaml(body); } catch { unreadable.push(name); continue; }
67
+ const http = asMap(asMap(doc).http);
68
+ for (const router of Object.keys(asMap(http.routers))) if (own.routers.includes(router)) found.push(`router ${router} (${name})`);
69
+ for (const service of Object.keys(asMap(http.services))) if (own.services.includes(service)) found.push(`service ${service} (${name})`);
70
+ }
71
+ return { found, unreadable };
72
+ }
73
+
74
+ /** Every service of the app stack is N/N with N >= 1. */
75
+ function stackHealth(run, stack) {
76
+ const listed = remoteDocker(run, ['stack', 'services', stack, '--format', '{{.Name}} {{.Replicas}}']);
77
+ const rows = String(listed.stdout).split('\n').map((line) => line.trim()).filter(Boolean).map((line) => line.split(' '));
78
+ const unhealthy = rows.filter(([, replicas]) => !HEALTHY.test(replicas ?? '')).map(([name]) => name);
79
+ return { ok: listed.status === 0 && rows.length > 0 && unhealthy.length === 0, unhealthy, count: rows.length };
80
+ }
81
+
82
+ function recipientOf(kit, config) {
83
+ try {
84
+ return { recipient: kit.readRecipient(config.repoRoot) };
85
+ } catch (error) {
86
+ return { error: error.message };
87
+ }
88
+ }
89
+
90
+ const legacyRunning = (run, ingress) => String(remoteDocker(run, ['inspect', '--format', '{{.State.Running}}', ingress.legacy.container]).stdout).trim() === 'true';
91
+
92
+ function preconditions(run, config, ingress, kit, context) {
93
+ const { own, live, recipient } = context;
94
+ const ignore = new Set([ingress.publish.file, ...ingress.publish.supersedes]);
95
+ const swarm = remoteDocker(run, ['info', '--format', '{{.Swarm.LocalNodeState}} {{.Swarm.ControlAvailable}}']);
96
+ const service = kit.serviceState(run, `${ingress.name}_${ingress.service}`);
97
+ const health = stackHealth(run, config.stack);
98
+ const clash = (own.error || live.error) ? { found: [], unreadable: [] } : clashes(live.files, own, ignore);
99
+ const checks = [
100
+ [`${ingress.routes} parses with routers and services`, !own.error, own.error],
101
+ [`${ingress.legacy.dynamicDir} is readable`, !live.error, live.error],
102
+ ['no router or service name clashes with a file of another tenant', clash.found.length === 0, clash.found.join(', ')],
103
+ ['every other file of the directory can be checked', clash.unreadable.length === 0, `not valid YAML: ${clash.unreadable.join(', ')}`],
104
+ ['swarm active (manager)', swarm.status === 0 && String(swarm.stdout).trim() === 'active true', 'this host is not an active swarm manager'],
105
+ [`ingress service ${ingress.name}_${ingress.service} is running`, service.desired >= 1 && service.running >= 1, 'run the takeover first'],
106
+ [`old edge ${ingress.legacy.container} is not running`, !legacyRunning(run, ingress), 'an old edge watching the same directory would load the new file too'],
107
+ [`every service of the stack ${config.stack} is healthy`, health.ok, health.count === 0 ? 'no service listed' : `not healthy: ${health.unhealthy.join(', ')}`],
108
+ ['age recipient and backup directory declared', recipient.error === undefined && ingress.backup !== null, recipient.error ?? 'declare deploy.ingress.backup'],
109
+ ];
110
+ return checks.map(([name, ok, detail]) => ({ name, ok, detail }));
111
+ }
112
+
113
+ // ---- remote writes: only publish.file, its .tmp and the superseded names ------------------------------------------------------------------
114
+
115
+ const remoteFile = (ingress, name) => `${ingress.legacy.dynamicDir}/${name}`;
116
+
117
+ function readRemote(run, ingress, name) {
118
+ const read = run(`cat -- ${quote(remoteFile(ingress, name))}`);
119
+ return read.status === 0 ? String(read.stdout) : null;
120
+ }
121
+
122
+ /** Uploads `content` as <name>.tmp (never a route file name: the file provider ignores it). Returns an error text or null. */
123
+ function upload(run, ingress, name, content) {
124
+ const tmp = remoteFile(ingress, `${name}.tmp`);
125
+ const done = run(shScript(`umask 022 && cat > ${quote(tmp)}`), { input: content });
126
+ return done.status === 0 ? null : `uploading ${name}.tmp FAILED: ${firstLine(done.stderr)}`;
127
+ }
128
+
129
+ /** Moves each <name>.tmp over <name> and removes the given files in ONE remote command. */
130
+ function swapIn(run, ingress, moves, removals) {
131
+ const steps = moves.map((name) => {
132
+ const tmp = remoteFile(ingress, `${name}.tmp`);
133
+ return `mv -f ${quote(tmp)} ${quote(remoteFile(ingress, name))}`;
134
+ });
135
+ if (removals.length > 0) steps.push(`rm -f ${removals.map((name) => quote(remoteFile(ingress, name))).join(' ')}`);
136
+ const done = run(shScript(steps.join(' && ')));
137
+ return done.status === 0 ? null : firstLine(done.stderr);
138
+ }
139
+
140
+ /** Age-encrypted copy of one file into the backup directory, fed through stdin (the content never touches argv). */
141
+ function backupOne(ctx, run, ingress, kit, { recipient, name, content }) {
142
+ const dir = ingress.backup.dir;
143
+ const file = `${dir}/${name}-${kit.stampOf(ctx.now())}.age`;
144
+ const script = ['umask 077', `mkdir -p ${quote(dir)}`, `age -r ${quote(recipient)} -o ${quote(file)}`, `test -s ${quote(file)}`].join(' && ');
145
+ const done = run(`bash -c ${quote(script)}`, { input: content });
146
+ return done.status === 0 ? { file } : { error: `backup of ${name} FAILED: ${firstLine(done.stderr)}` };
147
+ }
148
+
149
+ // ---- verification -----------------------------------------------------------------------------------------------------------------------
150
+
151
+ /** A host that did not answer before the swap (status 0: unrouted, with no certificate or the default one) is new: it gets a fresh certificate. */
152
+ const isNew = (entry) => !answers(entry.code);
153
+
154
+ /**
155
+ * After the swap: a declared status is exact; otherwise the baseline class. A new host must answer 2xx-4xx (curl verifies the certificate against the
156
+ * name, so an answer proves a valid certificate for it) and is never compared with its old default certificate; a host that answered before keeps
157
+ * its certificate.
158
+ */
159
+ function forwardOk(entry, seen) {
160
+ let statusOk = answers(seen.code);
161
+ if (entry.target.status !== null) statusOk = seen.code === entry.target.status;
162
+ else if (!isNew(entry)) statusOk = classOf(seen.code) === classOf(entry.code);
163
+ return statusOk && (isNew(entry) || entry.certificate === null || seen.certificate === entry.certificate);
164
+ }
165
+
166
+ /** After a restore: every host that answered before answers in the same class with the same certificate. */
167
+ const restoreOk = (entry, seen) => classOf(seen.code) === classOf(entry.code) && seen.certificate === entry.certificate;
168
+
169
+ function certificateState(entry, seen) {
170
+ if (isNew(entry) || entry.certificate === null) return 'new';
171
+ return seen.certificate === entry.certificate ? 'equal' : 'different';
172
+ }
173
+
174
+ function verify(ctx, run, kit, entries, accept) {
175
+ return kit.settle(ctx, VERIFY_ATTEMPTS, VERIFY_MS, () => {
176
+ const rows = entries.map((entry) => {
177
+ const seen = kit.observe(run, entry.target);
178
+ return { entry, code: seen.code, same: certificateState(entry, seen), ok: accept(entry, seen) };
179
+ });
180
+ return { ok: rows.every((row) => row.ok), rows };
181
+ });
182
+ }
183
+
184
+ function rowLine(row) {
185
+ return `${row.ok ? 'ok' : 'MISMATCH'} ${label(row.entry.target)}: status ${row.code} (baseline ${row.entry.code}), certificate serial ${row.same}`;
186
+ }
187
+
188
+ const hostsData = (rows) => rows.map((row) => ({ host: row.entry.target.host, path: row.entry.target.path, status: row.code, certificate: row.same }));
189
+
190
+ /** Writes the superseded files and the previous publish.file back from memory (one atomic swap; publish.file is deleted only when there was none) and re-verifies the baseline. */
191
+ async function restore(ctx, run, ingress, kit, context) {
192
+ const { contents, previous, base, steps } = context;
193
+ const back = new Map(contents);
194
+ if (previous !== null) back.set(ingress.publish.file, previous);
195
+ const names = [...back.keys()];
196
+ const failedUpload = names.map((name) => upload(run, ingress, name, back.get(name))).find((error) => error !== null);
197
+ const failedSwap = failedUpload ?? swapIn(run, ingress, names, previous === null ? [ingress.publish.file] : []);
198
+ steps.push('restore');
199
+ const manual = `RESTORE FAILED (manual: encrypted backups in ${ingress.backup.dir})`;
200
+ if (failedSwap) return { ok: false, line: manual };
201
+ const proof = await verify(ctx, run, kit, base.filter((entry) => answers(entry.code)), restoreOk);
202
+ steps.push('restore-verify');
203
+ return proof.ok ? { ok: true, line: 'RESTORED ok: the superseded files are back and every host answers as before' } : { ok: false, line: manual };
204
+ }
205
+
206
+ async function apply(ctx, run, ingress, kit, context) {
207
+ const { own, base, lines, steps, contents, backups, previous } = context;
208
+ const uploadError = upload(run, ingress, ingress.publish.file, own.text);
209
+ steps.push('upload');
210
+ if (uploadError) return result(false, [...lines, uploadError, 'publish refused: nothing was changed'], steps);
211
+ lines.push(`uploaded ${ingress.publish.file}.tmp`);
212
+ const swapError = swapIn(run, ingress, [ingress.publish.file], [...contents.keys()]);
213
+ steps.push('swap');
214
+ const proof = swapError ? { ok: false, rows: [] } : await verify(ctx, run, kit, base, forwardOk);
215
+ steps.push('verify');
216
+ lines.push(...proof.rows.map(rowLine));
217
+ if (!swapError && proof.ok) {
218
+ lines.push(`published ${ingress.publish.file}; retired ${[...contents.keys()].join(', ') || 'nothing'}; ${proof.rows.length} host(s) verified`);
219
+ return result(true, lines, steps, { backups, hosts: hostsData(proof.rows), restored: null });
220
+ }
221
+ lines.push(swapError ? `swap FAILED: ${swapError}` : 'verification FAILED');
222
+ const back = await restore(ctx, run, ingress, kit, { contents, previous, base, steps });
223
+ lines.push(back.line);
224
+ return result(false, lines, steps, { backups, hosts: hostsData(proof.rows), restored: back.ok ? 'ok' : 'failed' });
225
+ }
226
+
227
+ /** Reads and backs up every superseded file that exists, then the current publish.file (about to be overwritten); returns { contents, previous, backups } or { error }. */
228
+ function retire(ctx, run, ingress, kit, { recipient, names }) {
229
+ const contents = new Map();
230
+ const backups = [];
231
+ for (const name of names) {
232
+ const content = readRemote(run, ingress, name);
233
+ if (content === null) continue;
234
+ const saved = backupOne(ctx, run, ingress, kit, { recipient, name, content });
235
+ if (saved.error) return { error: saved.error };
236
+ contents.set(name, content);
237
+ backups.push(saved.file);
238
+ }
239
+ const previous = readRemote(run, ingress, ingress.publish.file);
240
+ if (previous !== null) {
241
+ const saved = backupOne(ctx, run, ingress, kit, { recipient, name: ingress.publish.file, content: previous });
242
+ if (saved.error) return { error: saved.error };
243
+ backups.push(saved.file);
244
+ }
245
+ return { contents, previous, backups };
246
+ }
247
+
248
+ /** `ingress publish`: see the top of the file. `kit` carries the shared probe helpers of ingress.mjs. */
249
+ export async function publishIngress(ctx, run, config, ingress, execute, kit) {
250
+ if (ingress.publish === null) throw refusal('STACK_INGRESS_PUBLISH_UNDECLARED', 'deploy.ingress.publish ({ file, supersedes }) is not declared: nothing to publish');
251
+ if (ingress.routes === null) throw refusal('STACK_INGRESS_PUBLISH_NO_ROUTES', 'deploy.ingress.routes is not declared: there is no routes file to publish');
252
+ const steps = ['preconditions'];
253
+ const own = readOwn(config, ingress);
254
+ const live = readFiles(run, kit, ingress.legacy.dynamicDir);
255
+ const { recipient, error } = recipientOf(kit, config);
256
+ const checks = preconditions(run, config, ingress, kit, { own, live, recipient: { error } });
257
+ const lines = checks.map((check) => (check.ok ? `precondition ok: ${check.name}` : `precondition FAILED: ${check.name} (${check.detail})`));
258
+ if (checks.some((check) => !check.ok)) return result(false, [...lines, 'publish refused: nothing was changed'], steps);
259
+ const present = ingress.publish.supersedes.filter((name) => live.files.has(name));
260
+ if (readRemote(run, ingress, ingress.publish.file) === own.text && present.length === 0) return result(true, [...lines, `nothing to do: ${ingress.publish.file} is current and no superseded file exists`], steps, { changed: false });
261
+ const base = kit.baselines(run, kit.targets(config, ingress));
262
+ steps.push('baseline');
263
+ for (const entry of base) lines.push(`baseline ${label(entry.target)}: status ${entry.code}`);
264
+ if (!execute) {
265
+ lines.push(`dry run: would back up ${present.join(', ') || 'no superseded file'} (age) to ${ingress.backup.dir}, upload ${ingress.publish.file}, swap it in, retire ${present.join(', ') || 'nothing'} and verify ${base.length} host(s), restoring on any failure`);
266
+ return result(true, lines, steps, { changed: false });
267
+ }
268
+ const retired = retire(ctx, run, ingress, kit, { recipient, names: present });
269
+ if (retired.error) return result(false, [...lines, retired.error, 'publish refused: nothing was changed'], steps);
270
+ steps.push('backup');
271
+ lines.push(`backups written: ${retired.backups.join(', ') || 'none needed'}`);
272
+ return apply(ctx, run, ingress, kit, { own, base, lines, steps, ...retired });
273
+ }