@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,706 @@
1
+ // ingress.mjs - `starci app stack ingress plan|attach|takeover`: the Swarm ingress becomes the SHARED ingress (80/443) of the VPS.
2
+ // The other tenants (plain docker compose systems on the same host) are reached over an attachable overlay network.
3
+ // plan read-only. Reads the dynamic route files of the OLD compose edge over ssh and compares their hosts with the declaration
4
+ // (declared upstreams + the app's own routes file). Any live host the declaration does not cover is a refusal (ok false).
5
+ // attach --execute: creates the attachable overlay network if missing and connects every upstream container to it (idempotent).
6
+ // takeover --execute: the cutover switch. All preconditions must pass or NOTHING changes; every host is probed on the old edge and on
7
+ // the new ingress (probe ports) BEFORE anything stops; then the old edge is stopped, the swarm ingress service is forced to
8
+ // rebind the freed ports and every host is verified on 443. Any failure after the stop triggers the AUTOMATIC FALLBACK
9
+ // (scale the swarm ingress to 0, start the old edge, re-verify it).
10
+ // Everything remote goes through the ssh runner (remote.mjs) as `docker ...` or `curl ...` strings built only from validated pieces
11
+ // (names, hosts, ports and paths are validated by stack-ingress-config.mjs; every argument is shell-quoted). Route files can carry
12
+ // header values or tokens: they are parsed in memory and ONLY host names and service origins (scheme://host:port) are ever printed.
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 { byCodeUnit } from '../runtime/engine/by-code-unit.mjs';
17
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
18
+ import { firstLine } from './context.mjs';
19
+ import { refusal } from './errors.mjs';
20
+ import { remoteDocker, shellQuote, withRunner } from './remote.mjs';
21
+ import { createIngress } from './ingress-create.mjs';
22
+ import { publishIngress } from './ingress-publish.mjs';
23
+
24
+ const MATCHER = /(Host|HostRegexp)\(([^)]*)\)/gu;
25
+ const QUOTED = /`([^`]*)`/gu;
26
+ const FILE_MARK = /^#@@FILE (.+)$/mu;
27
+ const SETTLE_ATTEMPTS = 6;
28
+ const SETTLE_MS = 2000;
29
+ const START_ATTEMPTS = 6;
30
+ const AGE_RECIPIENT = /age1[0-9a-z]{50,}/u;
31
+ const MOUNTPOINT = /^\/[A-Za-z0-9._/-]+$/u;
32
+
33
+ const asMap = (value) => (plain(value) ? value : {});
34
+ const sorted = (items) => [...items].sort(byCodeUnit);
35
+ const originOf = (url) => {
36
+ try {
37
+ const parsed = new URL(url);
38
+ return `${parsed.protocol}//${parsed.host}`;
39
+ } catch {
40
+ return '[unparsed]';
41
+ }
42
+ };
43
+
44
+ // ---- the live and the declared routes -------------------------------------------------------------------------------------------
45
+
46
+ /** Host keys of a proxy router rule: `a.example` for Host(...), `regexp:<pattern>` for HostRegexp(...). */
47
+ export function hostsOfRule(rule) {
48
+ const out = [];
49
+ for (const [, kind, args] of String(rule).matchAll(MATCHER)) {
50
+ for (const [, value] of args.matchAll(QUOTED)) out.push(kind === 'Host' ? value.toLowerCase() : `regexp:${value}`);
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** { routes: [{ host, targets }], middlewares: [name] } of one parsed file-provider dynamic document (any shape is tolerated). */
56
+ export function routesOfDocument(doc) {
57
+ const http = asMap(asMap(doc).http);
58
+ const services = asMap(http.services);
59
+ const targetsOf = (name) => {
60
+ const servers = asMap(asMap(services[String(name).split('@')[0]]).loadBalancer).servers;
61
+ return (Array.isArray(servers) ? servers : []).map((server) => originOf(String(asMap(server).url)));
62
+ };
63
+ const routes = Object.values(asMap(http.routers)).flatMap((router) => {
64
+ const targets = typeof asMap(router).service === 'string' ? targetsOf(router.service) : [];
65
+ return hostsOfRule(asMap(router).rule ?? '').map((host) => ({ host, targets }));
66
+ });
67
+ return { routes, middlewares: Object.keys(asMap(http.middlewares)) };
68
+ }
69
+
70
+ function liveCommand(dir) {
71
+ const loop = String.raw`for f in *.yml *.yaml; do if [ -f "$f" ]; then printf '#@@FILE %s\n' "$f"; cat "$f"; echo; fi; done`;
72
+ return `cd ${shellQuote(dir)} && ${loop}`;
73
+ }
74
+
75
+ /** Reads every route file of the old edge: { routes, files, unparsable }. */
76
+ function readLive(run, legacy) {
77
+ const result = run(liveCommand(legacy.dynamicDir));
78
+ if (result.status !== 0) return { error: `cannot read ${legacy.dynamicDir} on the manager: ${firstLine(result.stderr)}` };
79
+ const parts = String(result.stdout).split(FILE_MARK);
80
+ const live = { routes: [], files: [], unparsable: [] };
81
+ for (let index = 1; index < parts.length; index += 2) {
82
+ live.files.push(parts[index]);
83
+ try {
84
+ live.routes.push(...routesOfDocument(parseYaml(parts[index + 1])).routes);
85
+ } catch {
86
+ live.unparsable.push(parts[index]);
87
+ }
88
+ }
89
+ return live;
90
+ }
91
+
92
+ /** The app's own routes file (relative to the environment dir): { routes, middlewares } or an empty set. */
93
+ function readOwnRoutes(config, ingress) {
94
+ if (ingress.routes === null) return { routes: [], middlewares: [] };
95
+ try {
96
+ return routesOfDocument(parseYaml(fs.readFileSync(path.join(config.envDir, ingress.routes), 'utf8')));
97
+ } catch {
98
+ throw refusal('STACK_INGRESS_ROUTES', `cannot read ${ingress.routes} (deploy.ingress.routes) as YAML`);
99
+ }
100
+ }
101
+
102
+ /** Declared host -> where it is declared (`upstream <name>` or `routes file`). */
103
+ function declaredHosts(ingress, own) {
104
+ const out = new Map();
105
+ for (const entry of own.routes) out.set(entry.host, 'routes file');
106
+ for (const host of ingress.drop) out.set(host, 'dropped');
107
+ for (const entry of ingress.redirects) out.set(entry.host, `redirect to ${entry.to}`);
108
+ for (const entry of ingress.upstreams) for (const host of entry.hosts) out.set(host, `upstream ${entry.name}`);
109
+ return out;
110
+ }
111
+
112
+ function undeclaredMiddlewares(ingress, own) {
113
+ const known = new Set([...Object.keys(ingress.middlewares), ...own.middlewares]);
114
+ return ingress.upstreams.flatMap((entry) => entry.middlewares.filter((name) => !known.has(name)).map((name) => `${name} (upstream ${entry.name})`));
115
+ }
116
+
117
+ // ---- docker probes over ssh ---------------------------------------------------------------------------------------------------
118
+
119
+ function containerState(run, container, network) {
120
+ const inspect = remoteDocker(run, ['inspect', '--type', 'container', '--format', '{{.State.Running}}|{{json .NetworkSettings.Networks}}', container]);
121
+ if (inspect.status !== 0) return { present: false, attached: false };
122
+ const [running, json = 'null'] = String(inspect.stdout).trim().split(/\|(.*)/su);
123
+ let networks = null;
124
+ try { networks = JSON.parse(json); } catch { networks = null; }
125
+ return { present: running === 'true', attached: plain(networks) && Object.hasOwn(networks, network) };
126
+ }
127
+
128
+ /** 'missing' | 'wrong' (exists, not an attachable overlay) | 'ok'. */
129
+ function networkState(run, network) {
130
+ const inspect = remoteDocker(run, ['network', 'inspect', '--format', '{{.Driver}} {{.Attachable}}', network]);
131
+ if (inspect.status !== 0) return 'missing';
132
+ return String(inspect.stdout).trim() === 'overlay true' ? 'ok' : 'wrong';
133
+ }
134
+
135
+ const onOverlay = (ingress) => ingress.upstreams.filter((entry) => entry.container !== null);
136
+
137
+ const volumePresent = (run, volume) => remoteDocker(run, ['volume', 'inspect', volume]).status === 0;
138
+
139
+ // ---- plan ---------------------------------------------------------------------------------------------------------------------------
140
+
141
+ function gather(run, config, ingress) {
142
+ const own = readOwnRoutes(config, ingress);
143
+ const live = readLive(run, ingress.legacy);
144
+ if (live.error) return { error: live.error };
145
+ const declared = declaredHosts(ingress, own);
146
+ const liveHosts = new Map();
147
+ for (const entry of live.routes) liveHosts.set(entry.host, sorted(new Set([...(liveHosts.get(entry.host) ?? []), ...entry.targets])));
148
+ const containers = Object.fromEntries(onOverlay(ingress).map((entry) => [entry.container, containerState(run, entry.container, ingress.network)]));
149
+ return {
150
+ declared, liveHosts, live, own,
151
+ missing: sorted([...liveHosts.keys()].filter((host) => !declared.has(host))),
152
+ declaredNotLive: sorted([...declared.keys()].filter((host) => !liveHosts.has(host))),
153
+ badMiddlewares: undeclaredMiddlewares(ingress, own),
154
+ containers,
155
+ network: networkState(run, ingress.network),
156
+ certificates: volumePresent(run, ingress.acmeVolume),
157
+ };
158
+ }
159
+
160
+ function containerText(state) {
161
+ if (!state.present) return 'MISSING or not running';
162
+ return state.attached ? 'running, attached' : 'running, not attached';
163
+ }
164
+
165
+ function planLines(ingress, found) {
166
+ const lines = [`live edge files: ${found.live.files.length === 0 ? 'none' : found.live.files.join(', ')}`];
167
+ for (const file of found.live.unparsable) lines.push(`WARNING ${file} is not valid YAML: its routes could not be compared`);
168
+ for (const host of sorted(found.liveHosts.keys())) {
169
+ const state = found.declared.has(host) ? `declared (${found.declared.get(host)})` : 'NOT DECLARED';
170
+ lines.push(`live ${host} -> ${found.liveHosts.get(host).join(', ') || '(no target)'}: ${state}`);
171
+ }
172
+ for (const host of found.declaredNotLive) lines.push(`declared, not live yet: ${host} (${found.declared.get(host)})`);
173
+ for (const name of found.badMiddlewares) lines.push(`middleware ${name} is declared nowhere`);
174
+ for (const [name, state] of Object.entries(found.containers)) {
175
+ lines.push(`container ${name}: ${containerText(state)}`);
176
+ }
177
+ lines.push(`network ${ingress.network}: ${found.network}`, `certificates volume ${ingress.acmeVolume}: ${found.certificates ? 'present' : 'MISSING'}`);
178
+ return lines;
179
+ }
180
+
181
+ const planOk = (found) => found.missing.length === 0 && found.badMiddlewares.length === 0 && found.live.unparsable.length === 0;
182
+
183
+ function planResult(ingress, found) {
184
+ const lines = planLines(ingress, found);
185
+ const ok = planOk(found);
186
+ if (found.missing.length > 0) lines.push(`REFUSED: ${found.missing.length} live host(s) not covered by the declaration: ${found.missing.join(', ')}`);
187
+ lines.push(ok ? 'plan green: every live host is covered' : 'plan NOT green');
188
+ const data = {
189
+ ok, steps: ['plan'], live: sorted(found.liveHosts.keys()), declared: sorted(found.declared.keys()), missing: found.missing,
190
+ declaredNotLive: found.declaredNotLive, badMiddlewares: found.badMiddlewares, unparsable: found.live.unparsable,
191
+ containers: found.containers, network: found.network, certificates: found.certificates,
192
+ };
193
+ return { ok, lines, data };
194
+ }
195
+
196
+ function planAction(run, config, ingress) {
197
+ const found = gather(run, config, ingress);
198
+ if (found.error) return { ok: false, lines: [found.error], data: { ok: false, steps: ['plan'], error: found.error } };
199
+ return planResult(ingress, found);
200
+ }
201
+
202
+ // ---- attach -------------------------------------------------------------------------------------------------------------------------
203
+
204
+ function attachFailure(lines, steps, message) {
205
+ lines.push(message);
206
+ return { ok: false, lines, data: { ok: false, steps } };
207
+ }
208
+
209
+ function connectArgs(ingress, entry) {
210
+ return ['network', 'connect', ...entry.aliases.flatMap((alias) => ['--alias', alias]), '--gw-priority', '-1', ingress.network, entry.container];
211
+ }
212
+
213
+ /** Connects every upstream container; stops at the first failed command. */
214
+ function connectAll(run, ingress, pending, lines, steps) {
215
+ for (const entry of pending) {
216
+ const done = remoteDocker(run, connectArgs(ingress, entry));
217
+ if (done.status !== 0) return attachFailure(lines, steps, `connect ${entry.container} to ${ingress.network} FAILED: ${firstLine(done.stderr)}`);
218
+ steps.push(`connect:${entry.container}`);
219
+ lines.push(`connected ${entry.container} to ${ingress.network} (aliases ${entry.aliases.join(', ')})`);
220
+ }
221
+ return null;
222
+ }
223
+
224
+ /** Creates the overlay when it is missing (execute) or says it would; returns a failure result or null. */
225
+ function ensureNetwork(run, ingress, { network, execute, lines, steps }) {
226
+ if (network !== 'missing') return null;
227
+ if (!execute) {
228
+ lines.push(`dry run: would create the attachable overlay network ${ingress.network}`);
229
+ return null;
230
+ }
231
+ const created = remoteDocker(run, ['network', 'create', '--driver', 'overlay', '--attachable', ingress.network]);
232
+ if (created.status !== 0) return attachFailure(lines, steps, `creating ${ingress.network} FAILED: ${firstLine(created.stderr)}`);
233
+ steps.push('network-create');
234
+ lines.push(`created the attachable overlay network ${ingress.network}`);
235
+ return null;
236
+ }
237
+
238
+ function attachAction(run, ingress, execute) {
239
+ const lines = [];
240
+ const steps = [];
241
+ const states = onOverlay(ingress).map((entry) => ({ entry, state: containerState(run, entry.container, ingress.network) }));
242
+ const absent = states.filter(({ state }) => !state.present).map(({ entry }) => entry.container);
243
+ if (absent.length > 0) return attachFailure(lines, steps, `upstream container(s) missing or not running: ${absent.join(', ')}; nothing was changed`);
244
+ const network = networkState(run, ingress.network);
245
+ if (network === 'wrong') return attachFailure(lines, steps, `network ${ingress.network} exists but is not an attachable overlay; nothing was changed`);
246
+ const pending = states.filter(({ state }) => !state.attached).map(({ entry }) => entry);
247
+ for (const { entry, state } of states) if (state.attached) lines.push(`${entry.container} is already on ${ingress.network}: skipped`);
248
+ const failedNetwork = ensureNetwork(run, ingress, { network, execute, lines, steps });
249
+ if (failedNetwork) return failedNetwork;
250
+ const done = { ok: true, lines, data: { ok: true, steps, network, pending: pending.map((entry) => entry.container) } };
251
+ if (!execute) {
252
+ for (const entry of pending) lines.push(`dry run: would connect ${entry.container} to ${ingress.network} (aliases ${entry.aliases.join(', ')})`);
253
+ return done;
254
+ }
255
+ return connectAll(run, ingress, pending, lines, steps) ?? done;
256
+ }
257
+
258
+ // ---- takeover: probes ---------------------------------------------------------------------------------------------------------------
259
+
260
+ /** The hosts to prove: the declared `verify` list or every plain declared host that is not dropped (path /). */
261
+ function targetsOf(ingress, declared) {
262
+ if (ingress.verify.length > 0) return ingress.verify;
263
+ const plainHosts = [...declared.entries()].filter(([host, where]) => !host.startsWith('regexp:') && where !== 'dropped').map(([host]) => host);
264
+ return sorted(plainHosts).map((host) => ({ host, path: '/', status: null }));
265
+ }
266
+
267
+ const bashScript = (script) => `bash -c ${shellQuote(script)}`;
268
+
269
+ /** The URL and the Host stay exactly as in production (a redirect rule that matches on the URL must see the same text): an alternate port is reached with --connect-to, never by putting the port in the URL. */
270
+ function statusCommand(target, port) {
271
+ const route = port === 443 ? ['--resolve', `${target.host}:443:127.0.0.1`] : ['--connect-to', `${target.host}:443:127.0.0.1:${port}`];
272
+ const args = ['-sS', '--max-time', '10', ...route, '-o', '/dev/null', '-w', '%{http_code}', `https://${target.host}${target.path}`];
273
+ return `curl ${args.map(shellQuote).join(' ')}`;
274
+ }
275
+
276
+ function certificateCommand(target, port) {
277
+ const connect = ['openssl', 's_client', '-connect', `127.0.0.1:${port}`, '-servername', target.host].map(shellQuote).join(' ');
278
+ return bashScript(`printf '' | ${connect} 2>/dev/null | openssl x509 -noout -serial -subject`);
279
+ }
280
+
281
+ /** What 127.0.0.1:<port> serves for one host: the HTTP status (0 = connection or TLS error) and the certificate serial and subject (public data). */
282
+ function observe(run, target, port = 443) {
283
+ const status = run(statusCommand(target, port));
284
+ const code = String(status.stdout).trim();
285
+ const certificate = run(certificateCommand(target, port));
286
+ const text = String(certificate.stdout).trim();
287
+ return {
288
+ code: status.status === 0 && /^\d{3}$/u.test(code) ? Number(code) : 0,
289
+ certificate: certificate.status === 0 && text !== '' ? text : null,
290
+ };
291
+ }
292
+
293
+ const label = (target) => `${target.host}${target.path}`;
294
+ const answers = (code) => code >= 200 && code < 500;
295
+
296
+ /**
297
+ * The old edge is the baseline: it must answer every host and present a certificate, and whatever it answers IS the expectation of the takeover (the new
298
+ * ingress serves the same dynamic directory, so every host must answer exactly the same). The declared `verify.status` values describe the state AFTER
299
+ * `ingress publish` (for example a 308 redirect that only the app's own routes file creates) and are judged by publish and by `stack verify`.
300
+ */
301
+ function baselines(run, targets) {
302
+ return targets.map((target) => {
303
+ const seen = observe(run, target);
304
+ return { target, ...seen, ok: answers(seen.code) && seen.certificate !== null };
305
+ });
306
+ }
307
+
308
+ /** Runs `check` until it is ok or the attempts are used up, sleeping between attempts. */
309
+ async function settle(ctx, attempts, intervalMs, check) {
310
+ const last = await check();
311
+ if (last.ok || attempts <= 1) return last;
312
+ await ctx.sleep(intervalMs);
313
+ return settle(ctx, attempts - 1, intervalMs, check);
314
+ }
315
+
316
+ /** Every host answers the baseline status and presents the baseline certificate (retried while the edge boots). */
317
+ function matchBaseline(ctx, run, base) {
318
+ return settle(ctx, SETTLE_ATTEMPTS, SETTLE_MS, () => {
319
+ const rows = base.map((entry) => {
320
+ const seen = observe(run, entry.target);
321
+ return { target: entry.target, code: seen.code, ok: seen.code === entry.code && seen.certificate === entry.certificate };
322
+ });
323
+ return { ok: rows.every((row) => row.ok), rows };
324
+ });
325
+ }
326
+
327
+ const mismatches = (rows) => rows.filter((row) => !row.ok).map((row) => `${label(row.target)}=${row.code}`).join(', ');
328
+
329
+ // ---- takeover: backup ---------------------------------------------------------------------------------------------------------------
330
+
331
+ /** The first age recipient of the creation_rules of <repoRoot>/.sops.yaml (the custody's own public recipient). */
332
+ function readRecipient(repoRoot) {
333
+ let rules;
334
+ try { rules = parseYaml(fs.readFileSync(path.join(repoRoot, '.sops.yaml'), 'utf8'))?.creation_rules; } catch { rules = null; }
335
+ const found = AGE_RECIPIENT.exec(JSON.stringify(rules ?? null));
336
+ if (!found) throw refusal('STACK_INGRESS_RECIPIENT', 'no age recipient found in the creation_rules of .sops.yaml');
337
+ return found[0];
338
+ }
339
+
340
+ const stampOf = (epochMs) => new Date(epochMs).toISOString().slice(0, 19).replaceAll('-', '').replaceAll(':', '').replace('T', '-');
341
+
342
+ function archiveStep(run, { source, file, recipient, dir }) {
343
+ const steps = [
344
+ `mkdir -p ${shellQuote(dir)}`,
345
+ `sudo -n tar -cf - -C ${shellQuote(source)} . | age -r ${shellQuote(recipient)} -o ${shellQuote(file)}`,
346
+ `test -s ${shellQuote(file)}`,
347
+ ];
348
+ const done = run(bashScript(`set -o pipefail; umask 077; ${steps.join(' && ')}`));
349
+ return done.status === 0 ? null : firstLine(done.stderr);
350
+ }
351
+
352
+ /** Age-encrypted copies of the certificates store and of the dynamic directory, made ON the manager: { files } or { error }. */
353
+ function backupStores(ctx, run, ingress, recipient) {
354
+ const mount = remoteDocker(run, ['volume', 'inspect', '--format', '{{.Mountpoint}}', ingress.acmeVolume]);
355
+ const mountpoint = String(mount.stdout).trim();
356
+ if (mount.status !== 0 || !MOUNTPOINT.test(mountpoint)) return { error: `cannot locate the volume ${ingress.acmeVolume}` };
357
+ const { dir } = ingress.backup;
358
+ const stamp = stampOf(ctx.now());
359
+ const jobs = [
360
+ { source: mountpoint, file: `${dir}/acme-${stamp}.tar.age` },
361
+ { source: ingress.legacy.dynamicDir, file: `${dir}/dynamic-${stamp}.tar.age` },
362
+ ];
363
+ for (const job of jobs) {
364
+ const reason = archiveStep(run, { ...job, recipient, dir });
365
+ if (reason !== null) return { error: `backup ${job.file} FAILED: ${reason}` };
366
+ }
367
+ return { files: jobs.map((job) => job.file) };
368
+ }
369
+
370
+ // ---- takeover: preconditions --------------------------------------------------------------------------------------------------------
371
+
372
+ const serviceName = (ingress) => `${ingress.name}_${ingress.service}`;
373
+
374
+ /** { exists, desired, running } of a swarm service from the service listing (desired = the replica count the service is set to). */
375
+ function serviceState(run, name) {
376
+ const listed = remoteDocker(run, ['service', 'ls', '--filter', `name=${name}`, '--format', '{{.Name}} {{.Replicas}}']);
377
+ const row = String(listed.stdout).split('\n').map((line) => line.trim().split(' ')).find(([found]) => found === name);
378
+ if (listed.status !== 0 || !row) return { exists: false, desired: -1, running: -1 };
379
+ const [running, desired] = String(row[1]).split('/').map(Number);
380
+ return { exists: true, desired, running };
381
+ }
382
+
383
+ function preconditions(run, ingress, found) {
384
+ const swarm = remoteDocker(run, ['info', '--format', '{{.Swarm.LocalNodeState}} {{.Swarm.ControlAvailable}}']);
385
+ const service = serviceState(run, serviceName(ingress));
386
+ const detached = Object.entries(found.containers).filter(([, state]) => !state.attached).map(([name]) => name);
387
+ const checks = [
388
+ ['plan green', planOk(found), `${found.missing.length} live host(s) not covered`],
389
+ ['swarm active (manager)', swarm.status === 0 && String(swarm.stdout).trim() === 'active true', 'this host is not an active swarm manager'],
390
+ [`swarm service ${serviceName(ingress)} exists with 0 replicas (host-mode ports bind only when a task starts)`, service.exists && service.desired === 0, 'create it with replicas 0 first'],
391
+ [`network ${ingress.network} is an attachable overlay`, found.network === 'ok', `network state: ${found.network}`],
392
+ ['every upstream container attached', detached.length === 0, `not attached: ${detached.join(', ')}`],
393
+ [`certificates volume ${ingress.acmeVolume} present`, found.certificates, 'the persisted certificates volume is missing'],
394
+ ];
395
+ return checks.map(([name, ok, detail]) => ({ name, ok, detail }));
396
+ }
397
+
398
+ // ---- takeover: the switch -----------------------------------------------------------------------------------------------------------
399
+
400
+ const dockerLine = (args) => ['docker', ...args.map(shellQuote)].join(' ');
401
+
402
+ /** Waits until 127.0.0.1:443 accepts a TCP connection (every 100 ms, at most gateSeconds). */
403
+ const tcpLoop = (gateSeconds) => bashScript(`for i in $(seq 1 ${gateSeconds * 10}); do if (exec 3<>/dev/tcp/127.0.0.1/443) 2>/dev/null; then exit 0; fi; sleep 0.1; done; exit 1`);
404
+
405
+ /** T0: stop the old edge and start the ingress in one remote command; then wait for 443 and prove every host. Returns { failure, gapMs }. */
406
+ async function swap(ctx, run, ingress, base) {
407
+ const gateMs = ingress.takeover.gateSeconds * 1000;
408
+ const t0 = ctx.now();
409
+ const stop = dockerLine(['stop', '-t', '10', ingress.legacy.container]);
410
+ const start = dockerLine(['service', 'scale', `${serviceName(ingress)}=1`]);
411
+ const switched = run(`${stop} && ${start}`);
412
+ if (switched.status !== 0) return { failure: `stop and scale FAILED: ${firstLine(switched.stderr)}`, gapMs: ctx.now() - t0 };
413
+ const up = run(tcpLoop(ingress.takeover.gateSeconds));
414
+ const gapMs = ctx.now() - t0;
415
+ if (up.status !== 0 || gapMs > gateMs) return { failure: `port 443 was not accepting connections within ${ingress.takeover.gateSeconds} s`, gapMs };
416
+ const proof = await matchBaseline(ctx, run, base);
417
+ return proof.ok ? { failure: null, gapMs } : { failure: `hosts differ from the old edge: ${mismatches(proof.rows)}`, gapMs };
418
+ }
419
+
420
+ /** Automatic fallback: free the ports, start the old edge, prove it answers every host as before. */
421
+ async function fallback(ctx, run, ingress, base, steps) {
422
+ const name = serviceName(ingress);
423
+ const scale = remoteDocker(run, ['service', 'scale', `${name}=0`]);
424
+ steps.push('fallback-scale-0');
425
+ const manual = `docker service scale ${name}=0; docker start ${ingress.legacy.container}; check every host`;
426
+ const started = await settle(ctx, START_ATTEMPTS, SETTLE_MS, () => ({ ok: remoteDocker(run, ['start', ingress.legacy.container]).status === 0 }));
427
+ steps.push('fallback-start-old-edge');
428
+ if (scale.status !== 0 || !started.ok) return { ok: false, line: `FALLBACK FAILED (manual: ${manual})` };
429
+ const proof = await matchBaseline(ctx, run, base);
430
+ steps.push('fallback-verify');
431
+ if (!proof.ok) return { ok: false, line: `FALLBACK FAILED (manual: the old edge started but does not answer as before (${mismatches(proof.rows)}); ${manual})` };
432
+ return { ok: true, line: `FALLBACK ok: ${ingress.legacy.container} answers every host again; the ingress service is scaled to 0 (docker service scale ${name}=1 after fixing the cause)` };
433
+ }
434
+
435
+ const result = (ok, lines, steps, extra = {}) => ({ ok, lines, data: { ok, steps, ...extra } });
436
+
437
+ async function cutover(ctx, run, ingress, context) {
438
+ const { base, lines, steps, files } = context;
439
+ const outcome = await swap(ctx, run, ingress, base);
440
+ steps.push('stop-and-scale');
441
+ lines.push(`measured unavailability gap: ${outcome.gapMs} ms (gate ${ingress.takeover.gateSeconds} s)`);
442
+ if (outcome.failure === null) {
443
+ lines.push(`takeover complete: the swarm ingress serves ${base.length} host(s) with the old edge's certificates; ${ingress.legacy.container} is stopped, not removed`);
444
+ return result(true, lines, steps, { gapMs: outcome.gapMs, backups: files, fallback: null });
445
+ }
446
+ lines.push(`takeover FAILED: ${outcome.failure}`);
447
+ const back = await fallback(ctx, run, ingress, base, steps);
448
+ lines.push(back.line);
449
+ return result(false, lines, steps, { gapMs: outcome.gapMs, backups: files, fallback: back.ok ? 'ok' : 'failed' });
450
+ }
451
+
452
+ async function takeoverAction(ctx, run, config, ingress, execute) {
453
+ if (ingress.backup === null) throw refusal('STACK_INGRESS_BACKUP_UNDECLARED', 'deploy.ingress.backup (dir) is not declared: the takeover needs an encrypted backup');
454
+ const recipient = readRecipient(config.repoRoot);
455
+ const steps = ['plan'];
456
+ const found = gather(run, config, ingress);
457
+ if (found.error) return result(false, [found.error], steps);
458
+ const checks = preconditions(run, ingress, found);
459
+ const lines = checks.map((check) => (check.ok ? `precondition ok: ${check.name}` : `precondition FAILED: ${check.name} (${check.detail})`));
460
+ steps.push('preconditions');
461
+ if (checks.some((check) => !check.ok)) return result(false, [...lines, 'takeover refused: nothing was changed'], steps);
462
+ const base = baselines(run, targetsOf(ingress, found.declared));
463
+ steps.push('baseline');
464
+ for (const row of base) lines.push(`${row.ok ? 'baseline ok' : 'baseline FAILED'} ${label(row.target)}: old edge answers ${row.code}`);
465
+ if (base.some((row) => !row.ok)) return result(false, [...lines, 'takeover refused: the old edge does not answer as declared; nothing was changed'], steps);
466
+ if (!execute) {
467
+ lines.push(`dry run: would back up the certificates store and the dynamic directory (age) to ${ingress.backup.dir}, stop ${ingress.legacy.container} and scale ${serviceName(ingress)} to 1, verify ${base.length} host(s) and fall back on any failure`);
468
+ return result(true, lines, steps);
469
+ }
470
+ const saved = backupStores(ctx, run, ingress, recipient);
471
+ if (saved.error) return result(false, [...lines, saved.error, 'takeover refused: nothing was changed'], steps);
472
+ steps.push('backup');
473
+ lines.push(`backup written: ${saved.files.join(', ')}`);
474
+ return cutover(ctx, run, ingress, { base, lines, steps, files: saved.files });
475
+ }
476
+
477
+ // ---- rehearse -----------------------------------------------------------------------------------------------------------------------
478
+ // A throwaway copy of the ingress on alternate host ports, proving that the persisted certificates and the dynamic routes serve every host
479
+ // exactly as the live edge does. The live edge, its container, the production certificates volume and ports 80/443 are never touched: the
480
+ // store is only READ (read-only mount of a helper container) into a NEW volume, the service reads the legacy dynamic directory read-only,
481
+ // the ACME http challenge is moved to an unused internal entrypoint so no certificate can be requested, and everything created is torn
482
+ // down in a finally path.
483
+
484
+ const UNUSED_ENTRYPOINT = 'rehearsal-unused';
485
+ const CHALLENGE_ARG = /^(--certificatesresolvers\.[^.]+\.acme\.httpchallenge\.entrypoint)=.*$/iu;
486
+ const ACME_ACTIVITY = /\b(?:obtain|obtaining|registering|new account|authorization)\b/iu;
487
+ const READY_ATTEMPTS = 12;
488
+
489
+ const rehearsalName = (ingress) => `${ingress.name}-rehearsal`;
490
+ const rehearsalVolume = (ingress) => `${ingress.acmeVolume}-rehearsal`;
491
+ const classOf = (code) => Math.floor(code / 100);
492
+
493
+ /** The static arguments of the ingress service with the ACME http challenge moved to an unused internal entrypoint. */
494
+ export function rehearsalArgs(args) {
495
+ const quiet = args.map((arg) => arg.replace(CHALLENGE_ARG, `$1=${UNUSED_ENTRYPOINT}`));
496
+ return [...quiet, `--entrypoints.${UNUSED_ENTRYPOINT}.address=127.0.0.1:1`];
497
+ }
498
+
499
+ /** Image, static arguments and the container paths of the two mounts the rehearsal copies; null when the service cannot be rehearsed. */
500
+ function ingressSpec(run, ingress) {
501
+ const inspect = remoteDocker(run, ['service', 'inspect', '--format', '{{json .Spec.TaskTemplate.ContainerSpec}}', serviceName(ingress)]);
502
+ let spec = null;
503
+ try { spec = JSON.parse(inspect.stdout); } catch { spec = null; }
504
+ const mounts = Array.isArray(spec?.Mounts) ? spec.Mounts : [];
505
+ const targetOf = (source) => mounts.find((mount) => asMap(mount).Source === source)?.Target;
506
+ const out = {
507
+ image: spec?.Image,
508
+ args: (Array.isArray(spec?.Args) ? spec.Args : []).filter((arg) => typeof arg === 'string'),
509
+ dynamicTarget: targetOf(ingress.legacy.dynamicDir),
510
+ acmeTarget: targetOf(ingress.acmeVolume),
511
+ };
512
+ const complete = typeof out.image === 'string' && out.args.length > 0 && typeof out.dynamicTarget === 'string' && typeof out.acmeTarget === 'string';
513
+ return inspect.status === 0 && complete ? out : null;
514
+ }
515
+
516
+ function rehearseChecks(run, config, ingress, found, spec) {
517
+ const leftoverService = remoteDocker(run, ['service', 'inspect', rehearsalName(ingress)]).status === 0;
518
+ const leftoverVolume = volumePresent(run, rehearsalVolume(ingress));
519
+ const checks = [
520
+ ['plan green', planOk(found), `${found.missing.length} live host(s) not covered`],
521
+ [`network ${ingress.network} is an attachable overlay`, found.network === 'ok', `network state: ${found.network}`],
522
+ [`certificates volume ${ingress.acmeVolume} present`, found.certificates, 'the persisted certificates volume is missing'],
523
+ ['helper image declared (deploy.backup.image)', typeof config.backup?.image === 'string', 'the copy of the store needs a digest-pinned helper image'],
524
+ [`swarm service ${serviceName(ingress)} readable (image, arguments, dynamic and certificates mounts)`, spec !== null, 'create the ingress service first'],
525
+ [`no leftover ${rehearsalName(ingress)} service or ${rehearsalVolume(ingress)} volume`, !leftoverService && !leftoverVolume, 'remove the leftover of an earlier rehearsal first'],
526
+ ];
527
+ return checks.map(([name, ok, detail]) => ({ name, ok, detail }));
528
+ }
529
+
530
+ function serviceCreateArgs(ingress, spec) {
531
+ const ports = ingress.takeover.rehearsalPorts;
532
+ const mounts = [
533
+ `type=bind,source=${ingress.legacy.dynamicDir},target=${spec.dynamicTarget},readonly`,
534
+ `type=volume,source=${rehearsalVolume(ingress)},target=${spec.acmeTarget}`,
535
+ ];
536
+ return [
537
+ 'service', 'create', '--detach', '--name', rehearsalName(ingress), '--replicas', '1', '--restart-condition', 'none', '--network', ingress.network,
538
+ '--publish', `mode=host,target=80,published=${ports.http}`, '--publish', `mode=host,target=443,published=${ports.https}`,
539
+ ...mounts.flatMap((mount) => ['--mount', mount]), spec.image, ...rehearsalArgs(spec.args),
540
+ ];
541
+ }
542
+
543
+ /** Volume, read-only copy of the store, service: stops at the first failed step. Returns a failure message or null. */
544
+ async function rehearsalUp(ctx, run, config, ingress, context) {
545
+ const { spec, steps, created } = context;
546
+ const volume = rehearsalVolume(ingress);
547
+ const made = remoteDocker(run, ['volume', 'create', volume]);
548
+ if (made.status !== 0) return `creating ${volume} FAILED: ${firstLine(made.stderr)}`;
549
+ created.volume = true;
550
+ steps.push('volume-create');
551
+ const copy = remoteDocker(run, ['run', '--rm', '--network', 'none', '-v', `${ingress.acmeVolume}:/from:ro`, '-v', `${volume}:/to`, config.backup.image, 'cp', '-a', '/from/.', '/to/']);
552
+ if (copy.status !== 0) return `copying the certificates store FAILED: ${firstLine(copy.stderr)}`;
553
+ steps.push('volume-copy');
554
+ const service = remoteDocker(run, serviceCreateArgs(ingress, spec));
555
+ if (service.status !== 0) return `creating ${rehearsalName(ingress)} FAILED: ${firstLine(service.stderr)}`;
556
+ created.service = true;
557
+ steps.push('service-create');
558
+ const ready = await settle(ctx, READY_ATTEMPTS, SETTLE_MS, () => {
559
+ const state = serviceState(run, rehearsalName(ingress));
560
+ return { ok: state.desired > 0 && state.running === state.desired };
561
+ });
562
+ steps.push('service-ready');
563
+ return ready.ok ? null : `${rehearsalName(ingress)} did not start`;
564
+ }
565
+
566
+ /** Status class and certificate of every host on the rehearsal ports against the live edge baseline; hosts without a live certificate are skipped. */
567
+ function compareRehearsal(run, ingress, base) {
568
+ const port = ingress.takeover.rehearsalPorts.https;
569
+ return base.map((entry) => {
570
+ if (entry.certificate === null) return { target: entry.target, live: entry.code, skipped: true, ok: true };
571
+ const seen = observe(run, entry.target, port);
572
+ const sameStatus = classOf(seen.code) === classOf(entry.code);
573
+ const sameCertificate = seen.certificate === entry.certificate;
574
+ return { target: entry.target, live: entry.code, code: seen.code, sameCertificate, skipped: false, ok: sameStatus && sameCertificate };
575
+ });
576
+ }
577
+
578
+ function certificateVerdict(row) {
579
+ if (row.skipped) return 'skipped';
580
+ return row.sameCertificate ? 'equal' : 'different';
581
+ }
582
+
583
+ function rowLine(row) {
584
+ if (row.skipped) return `skipped: needs fresh issuance ${label(row.target)} (the live edge presents no certificate for it)`;
585
+ return `${row.ok ? 'ok' : 'MISMATCH'} ${label(row.target)}: status ${row.code} (live ${row.live}), certificate serial and subject ${certificateVerdict(row)}`;
586
+ }
587
+
588
+ /** The rehearsal log must show no certificate request (obtain, register, authorization). Returns a failure message or null. */
589
+ function acmeLogProblem(run, ingress) {
590
+ const logs = remoteDocker(run, ['service', 'logs', '--raw', '--no-task-ids', rehearsalName(ingress)]);
591
+ if (logs.status !== 0) return 'cannot read the rehearsal log: no proof that no certificate was requested';
592
+ return String(logs.stdout).split('\n').some((line) => ACME_ACTIVITY.test(line)) ? 'the rehearsal log shows certificate activity (a certificate was requested)' : null;
593
+ }
594
+
595
+ /** Brings the rehearsal up, probes every host and checks the log. Returns { failure, rows }. */
596
+ async function rehearsalBody(ctx, run, config, ingress, context) {
597
+ const failure = await rehearsalUp(ctx, run, config, ingress, context);
598
+ if (failure !== null) return { failure, rows: [] };
599
+ const compared = await settle(ctx, SETTLE_ATTEMPTS, SETTLE_MS, () => {
600
+ const rows = compareRehearsal(run, ingress, context.base);
601
+ return { ok: rows.every((row) => row.ok), rows };
602
+ });
603
+ context.steps.push('probe');
604
+ const problem = acmeLogProblem(run, ingress);
605
+ context.steps.push('acme-log');
606
+ const mismatched = compared.rows.filter((row) => !row.ok).map((row) => label(row.target));
607
+ const failures = [mismatched.length > 0 ? `mismatch on ${mismatched.join(', ')}` : null, problem].filter(Boolean);
608
+ return { failure: failures.length > 0 ? failures.join('; ') : null, rows: compared.rows };
609
+ }
610
+
611
+ /** Removes what was created: the service, then its tasks, then the volume. Always reports each step. */
612
+ async function rehearsalTeardown(ctx, run, ingress, created, steps) {
613
+ const lines = [];
614
+ let clean = true;
615
+ const record = (name, done, text) => {
616
+ steps.push(`teardown:${name}`);
617
+ lines.push(done ? `teardown: ${text}` : `TEARDOWN FAILED: ${text} not done`);
618
+ clean = clean && done;
619
+ };
620
+ if (created.service) {
621
+ record('service-rm', remoteDocker(run, ['service', 'rm', rehearsalName(ingress)]).status === 0, 'service removed');
622
+ const gone = await settle(ctx, READY_ATTEMPTS, SETTLE_MS, () => ({ ok: String(remoteDocker(run, ['ps', '-q', '--filter', `label=com.docker.swarm.service.name=${rehearsalName(ingress)}`]).stdout).trim() === '' }));
623
+ record('tasks-gone', gone.ok, 'tasks gone');
624
+ }
625
+ if (created.volume) {
626
+ const removed = await settle(ctx, SETTLE_ATTEMPTS, SETTLE_MS, () => ({ ok: remoteDocker(run, ['volume', 'rm', rehearsalVolume(ingress)]).status === 0 }));
627
+ record('volume-rm', removed.ok, 'volume removed');
628
+ }
629
+ if (!clean) lines.push(`manual: remove the service ${rehearsalName(ingress)} and the volume ${rehearsalVolume(ingress)}`);
630
+ return { clean, lines };
631
+ }
632
+
633
+ async function rehearsalRun(ctx, run, config, ingress, context) {
634
+ const created = { volume: false, service: false };
635
+ let body = { failure: 'rehearsal did not run', rows: [] };
636
+ let down = { clean: true, lines: [] };
637
+ try {
638
+ body = await rehearsalBody(ctx, run, config, ingress, { ...context, created });
639
+ } finally {
640
+ down = await rehearsalTeardown(ctx, run, ingress, created, context.steps);
641
+ }
642
+ const lines = [...context.lines, ...body.rows.map(rowLine), ...down.lines];
643
+ if (body.failure !== null) lines.push(`rehearsal FAILED: ${body.failure}`);
644
+ const ok = body.failure === null && down.clean;
645
+ if (ok) lines.push('rehearsal passed: every host matches the live edge and nothing of the rehearsal is left');
646
+ const hosts = body.rows.map((row) => ({ host: row.target.host, path: row.target.path, status: row.code ?? null, certificate: certificateVerdict(row) }));
647
+ return result(ok, lines, context.steps, { hosts, teardown: down.clean ? 'clean' : 'failed' });
648
+ }
649
+
650
+ async function rehearseAction(ctx, run, config, ingress, execute) {
651
+ const steps = ['plan'];
652
+ const found = gather(run, config, ingress);
653
+ if (found.error) return result(false, [found.error], steps);
654
+ const spec = ingressSpec(run, ingress);
655
+ const checks = rehearseChecks(run, config, ingress, found, spec);
656
+ const lines = checks.map((check) => (check.ok ? `precondition ok: ${check.name}` : `precondition FAILED: ${check.name} (${check.detail})`));
657
+ steps.push('preconditions');
658
+ if (checks.some((check) => !check.ok)) return result(false, [...lines, 'rehearsal refused: nothing was changed'], steps);
659
+ const base = baselines(run, targetsOf(ingress, found.declared));
660
+ steps.push('baseline');
661
+ for (const row of base) lines.push(`live edge ${label(row.target)}: status ${row.code}`);
662
+ if (!execute) {
663
+ const ports = ingress.takeover.rehearsalPorts;
664
+ lines.push(`dry run: would copy ${ingress.acmeVolume} read-only into ${rehearsalVolume(ingress)}, start ${rehearsalName(ingress)} on host ports ${ports.http}/${ports.https} with no certificate requests, compare ${base.length} host(s) with the live edge and tear everything down`);
665
+ return result(true, lines, steps);
666
+ }
667
+ return rehearsalRun(ctx, run, config, ingress, { spec, base, lines, steps });
668
+ }
669
+
670
+ // ---- entry --------------------------------------------------------------------------------------------------------------------------
671
+
672
+ /** The probe helpers the publish action shares with the takeover (injected so the two modules never import each other). */
673
+ const PUBLISH_KIT = {
674
+ readRecipient,
675
+ stampOf,
676
+ observe,
677
+ baselines,
678
+ settle,
679
+ serviceState,
680
+ liveCommand,
681
+ targets: (config, ingress) => targetsOf(ingress, declaredHosts(ingress, readOwnRoutes(config, ingress))),
682
+ };
683
+
684
+ const ACTIONS = ['plan', 'attach', 'create', 'takeover', 'rehearse', 'publish'];
685
+
686
+ /**
687
+ * `starci app stack ingress <plan|attach|create|takeover|rehearse|publish>`; effects need execute === true.
688
+ * deps.ssh an injected ssh runner (specs).
689
+ */
690
+ export async function runIngress(ctx, config, { action, execute = false, args = [], sshCmd = null }, deps = {}) {
691
+ if (!ACTIONS.includes(action)) throw refusal('STACK_USAGE', `starci app stack ingress takes ${ACTIONS.join(' | ')}`);
692
+ if (args.length > 0) throw refusal('STACK_USAGE', `ingress ${action} takes no further argument (got ${args.join(' ')})`);
693
+ const { ingress } = config;
694
+ if (!ingress) throw refusal('STACK_INGRESS_UNDECLARED', 'deploy.ingress is not declared for this environment');
695
+ return withRunner(ctx, config, { sshCmd }, deps, async (run, notes) => {
696
+ const outcome = await {
697
+ plan: () => planAction(run, config, ingress),
698
+ attach: () => attachAction(run, ingress, execute),
699
+ create: () => createIngress(run, ingress, execute),
700
+ takeover: () => takeoverAction(ctx, run, config, ingress, execute),
701
+ rehearse: () => rehearseAction(ctx, run, config, ingress, execute),
702
+ publish: () => publishIngress(ctx, run, config, ingress, execute, PUBLISH_KIT),
703
+ }[action]();
704
+ return { ...outcome, lines: [...notes, ...outcome.lines], data: { action, ...outcome.data } };
705
+ });
706
+ }