@starci/hfs 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (515) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/db/migrate.mjs +361 -0
  3. package/db/post-cutover.mjs +136 -0
  4. package/emit/db-types.mjs +10 -1
  5. package/lint/run.mjs +1 -1
  6. package/package.json +4 -1
  7. package/release/gate.mjs +298 -0
  8. package/release/index.mjs +103 -0
  9. package/release/push.mjs +110 -0
  10. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  11. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  12. package/runtime/knowledge/hfs/rules.yaml +3401 -152
  13. package/runtime/knowledge/hfs/slots.yaml +344 -62
  14. package/runtime/knowledge/patterns/be/api.yaml +32 -5
  15. package/runtime/knowledge/patterns/be/cli.yaml +14 -4
  16. package/runtime/knowledge/patterns/be/event-bus.yaml +3 -3
  17. package/runtime/knowledge/patterns/be/jobs.yaml +20 -2
  18. package/runtime/knowledge/patterns/be/projections.yaml +4 -2
  19. package/runtime/knowledge/patterns/be/queues.yaml +11 -3
  20. package/runtime/knowledge/patterns/be/reactors.yaml +20 -1
  21. package/runtime/knowledge/patterns/be/realtime.yaml +25 -3
  22. package/runtime/knowledge/patterns/be/saga.yaml +20 -3
  23. package/runtime/knowledge/patterns/be/webhooks.yaml +23 -4
  24. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  25. package/runtime/knowledge/sonar-gate.yaml +1 -1
  26. package/runtime/modules/kernel/failure-codes.yaml +3113 -43
  27. package/runtime/modules/models/runtimes.yaml +1 -1
  28. package/runtime/modules/schemas/application-stacks.schema.yaml +663 -0
  29. package/runtime/packages/cli/src/catalog.generated.mjs +14104 -0
  30. package/runtime/packages/cli/src/validate-args.mjs +213 -0
  31. package/runtime/scripts/api/git/add.mjs +5 -0
  32. package/runtime/scripts/api/git/init.mjs +7 -0
  33. package/runtime/scripts/api/git/lib.mjs +3 -0
  34. package/runtime/scripts/api/git/tag.mjs +5 -0
  35. package/runtime/scripts/api/node/lib.mjs +20 -0
  36. package/runtime/scripts/api/node/run-node.mjs +6 -0
  37. package/runtime/scripts/api/sops/lib.mjs +41 -0
  38. package/runtime/scripts/api/sops/selected-age-recipient.mjs +7 -0
  39. package/runtime/scripts/api/supabase/database-dump.mjs +5 -0
  40. package/runtime/scripts/api/supabase/database-query.mjs +5 -0
  41. package/runtime/scripts/api/supabase/lib.mjs +44 -0
  42. package/runtime/scripts/api/supabase/migration-repair.mjs +5 -0
  43. package/runtime/scripts/hfs/app-wiring.mjs +201 -0
  44. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  45. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  46. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  47. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  48. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  49. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  50. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  51. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  52. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  53. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  54. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  55. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  56. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  57. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  58. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  59. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  60. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  61. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  62. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  63. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  64. package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
  65. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  66. package/runtime/scripts/hfs/check-results.mjs +28 -0
  67. package/runtime/scripts/hfs/check.mjs +46 -47
  68. package/runtime/scripts/hfs/coverage-from.mjs +70 -0
  69. package/runtime/scripts/hfs/db-baseline-verb.mjs +276 -0
  70. package/runtime/scripts/hfs/db-plan-verb.mjs +254 -0
  71. package/runtime/scripts/hfs/db-restamp-verb.mjs +60 -0
  72. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  73. package/runtime/scripts/hfs/doc-commands.mjs +367 -0
  74. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  75. package/runtime/scripts/hfs/example-secrets.mjs +158 -0
  76. package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
  77. package/runtime/scripts/hfs/refusal-codes.mjs +6 -0
  78. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  79. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  80. package/runtime/scripts/hfs/rules/a15-findings.mjs +88 -0
  81. package/runtime/scripts/hfs/rules/app-env-required.mjs +20 -0
  82. package/runtime/scripts/hfs/rules/app-root.mjs +2 -2
  83. package/runtime/scripts/hfs/rules/assets.mjs +79 -0
  84. package/runtime/scripts/hfs/rules/boot-proof.mjs +16 -0
  85. package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +59 -0
  86. package/runtime/scripts/hfs/rules/contract.mjs +66 -5
  87. package/runtime/scripts/hfs/rules/coverage-below.mjs +230 -0
  88. package/runtime/scripts/hfs/rules/database-access.mjs +343 -0
  89. package/runtime/scripts/hfs/rules/database-baseline.mjs +142 -0
  90. package/runtime/scripts/hfs/rules/database-config.mjs +250 -2
  91. package/runtime/scripts/hfs/rules/database-constants.mjs +9 -0
  92. package/runtime/scripts/hfs/rules/database-migrations.mjs +286 -7
  93. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +119 -9
  94. package/runtime/scripts/hfs/rules/database-queue-sources.mjs +176 -0
  95. package/runtime/scripts/hfs/rules/database-queues.mjs +361 -0
  96. package/runtime/scripts/hfs/rules/database-sql.mjs +91 -9
  97. package/runtime/scripts/hfs/rules/database.mjs +9 -0
  98. package/runtime/scripts/hfs/rules/deploy-scan.mjs +192 -0
  99. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +219 -0
  100. package/runtime/scripts/hfs/rules/doc-fresh.mjs +286 -0
  101. package/runtime/scripts/hfs/rules/docker-health.mjs +249 -0
  102. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  103. package/runtime/scripts/hfs/rules/edition.mjs +9 -8
  104. package/runtime/scripts/hfs/rules/env-parity.mjs +373 -0
  105. package/runtime/scripts/hfs/rules/env-value-shape.mjs +113 -0
  106. package/runtime/scripts/hfs/rules/event-bus.mjs +3 -2
  107. package/runtime/scripts/hfs/rules/event-tables.mjs +100 -0
  108. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +115 -15
  109. package/runtime/scripts/hfs/rules/fe-stack.mjs +57 -0
  110. package/runtime/scripts/hfs/rules/feature-files.mjs +28 -0
  111. package/runtime/scripts/hfs/rules/frontend-tree.mjs +54 -35
  112. package/runtime/scripts/hfs/rules/integration-specs.mjs +3 -2
  113. package/runtime/scripts/hfs/rules/lexicon.mjs +62 -0
  114. package/runtime/scripts/hfs/rules/lite-proof-lane.mjs +89 -0
  115. package/runtime/scripts/hfs/rules/monorepo.mjs +14 -1
  116. package/runtime/scripts/hfs/rules/pipeline.mjs +8 -1
  117. package/runtime/scripts/hfs/rules/release-provenance.mjs +211 -0
  118. package/runtime/scripts/hfs/rules/saga-delivery.mjs +66 -0
  119. package/runtime/scripts/hfs/rules/saga.mjs +8 -16
  120. package/runtime/scripts/hfs/rules/script-hygiene.mjs +151 -0
  121. package/runtime/scripts/hfs/rules/secret-custody-only.mjs +65 -0
  122. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  123. package/runtime/scripts/hfs/rules/services.mjs +4 -4
  124. package/runtime/scripts/hfs/rules/stack-tooling.mjs +93 -0
  125. package/runtime/scripts/hfs/rules/stacks-deploy.mjs +76 -0
  126. package/runtime/scripts/hfs/rules/stacks-ingress.mjs +87 -0
  127. package/runtime/scripts/hfs/rules/stacks-lite-backing.mjs +111 -0
  128. package/runtime/scripts/hfs/rules/stacks-release.mjs +366 -0
  129. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +41 -1
  130. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +417 -0
  131. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  132. package/runtime/scripts/hfs/rules/supply-chain.mjs +167 -0
  133. package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +101 -0
  134. package/runtime/scripts/hfs/rules/unused-symbol-ledger.mjs +77 -0
  135. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +165 -0
  136. package/runtime/scripts/hfs/rules/unused-symbol-values.mjs +103 -0
  137. package/runtime/scripts/hfs/rules/unused-symbol.mjs +320 -0
  138. package/runtime/scripts/hfs/runtime-rules/source-ast.mjs +185 -0
  139. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +19 -0
  140. package/runtime/scripts/hfs/sealed-members.mjs +101 -0
  141. package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +200 -0
  142. package/runtime/scripts/hfs/secret-edit.mjs +170 -0
  143. package/runtime/scripts/hfs/secret-init.mjs +127 -19
  144. package/runtime/scripts/hfs/secret-sops.mjs +38 -6
  145. package/runtime/scripts/hfs/secret-store.mjs +56 -54
  146. package/runtime/scripts/hfs/secret.mjs +34 -5
  147. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  148. package/runtime/scripts/hfs/slots.mjs +1 -0
  149. package/runtime/scripts/hfs/stack-config-kit.mjs +34 -0
  150. package/runtime/scripts/hfs/stack-deploy-config.mjs +313 -0
  151. package/runtime/scripts/hfs/stack-ingress-config.mjs +237 -0
  152. package/runtime/scripts/hfs/stacks-gate.mjs +414 -0
  153. package/runtime/scripts/hfs/starcistacks-runtime.mjs +61 -0
  154. package/runtime/scripts/lib/command-tokens.mjs +126 -0
  155. package/runtime/scripts/lib/compose-values.mjs +11 -0
  156. package/runtime/scripts/lib/deployment-findings.mjs +7 -0
  157. package/runtime/scripts/lib/json-clone.mjs +61 -0
  158. package/runtime/scripts/lib/list.mjs +4 -0
  159. package/runtime/scripts/lib/runtime-example.mjs +13 -0
  160. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  161. package/runtime/scripts/lib/sql-ast.mjs +32 -0
  162. package/runtime/scripts/lib/stack-services.mjs +1 -1
  163. package/runtime/scripts/lib/swarm-stack.mjs +5 -18
  164. package/runtime/scripts/lib/trim-end.mjs +8 -0
  165. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  166. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  167. package/runtime/scripts/lib/workflow-events.mjs +8 -0
  168. package/scaffold/add-app.mjs +77 -59
  169. package/scaffold/add-cli-lite.mjs +14 -6
  170. package/scaffold/add-compose.mjs +79 -0
  171. package/scaffold/add-database-lite.mjs +14 -0
  172. package/scaffold/add-fe-overlay.mjs +122 -0
  173. package/scaffold/add-fe-page.mjs +269 -0
  174. package/scaffold/add-inbox-lite.mjs +74 -42
  175. package/scaffold/add-table.mjs +110 -87
  176. package/scaffold/add.mjs +159 -44
  177. package/scaffold/app.mjs +124 -39
  178. package/scaffold/edition-gate.mjs +3 -3
  179. package/scaffold/lite-exports.mjs +2 -1
  180. package/scaffold/service.mjs +2 -2
  181. package/src/main.mjs +229 -116
  182. package/stack/age-install.mjs +37 -0
  183. package/stack/backup.mjs +100 -0
  184. package/stack/build-args.mjs +42 -0
  185. package/stack/build.mjs +173 -0
  186. package/stack/cli.mjs +130 -0
  187. package/stack/config.mjs +57 -0
  188. package/stack/context.mjs +57 -0
  189. package/stack/converge.mjs +107 -0
  190. package/stack/deploy.mjs +301 -0
  191. package/stack/digest.mjs +16 -0
  192. package/stack/docker-transport.mjs +53 -0
  193. package/stack/edge-alias.mjs +118 -0
  194. package/stack/errors.mjs +21 -0
  195. package/stack/image-scan.mjs +152 -0
  196. package/stack/ingress-create.mjs +114 -0
  197. package/stack/ingress-publish.mjs +273 -0
  198. package/stack/ingress.mjs +706 -0
  199. package/stack/jobs.mjs +51 -0
  200. package/stack/live-restore.mjs +177 -0
  201. package/stack/prepare.mjs +266 -0
  202. package/stack/private-file.mjs +22 -0
  203. package/stack/provision.mjs +90 -0
  204. package/stack/prune.mjs +46 -0
  205. package/stack/release-store.mjs +87 -0
  206. package/stack/remote.mjs +205 -0
  207. package/stack/render.mjs +149 -0
  208. package/stack/retire.mjs +47 -0
  209. package/stack/sealed.mjs +149 -0
  210. package/stack/task.mjs +42 -0
  211. package/stack/verify.mjs +99 -0
  212. package/sync/ci-commands.mjs +13 -0
  213. package/sync/index.mjs +108 -52
  214. package/sync/stack-images.mjs +127 -0
  215. package/templates/app/ci-workflows/github/workflows/ci.yml +5 -1
  216. package/templates/app/ci-workflows/github/workflows/images.yml +15 -3
  217. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +148 -0
  218. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +7 -3
  219. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +38 -6
  220. package/templates/app/ci-workflows-lite/github/workflows/images.yml +14 -2
  221. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +148 -0
  222. package/templates/app/codegen-graphql.mjs.partial +13 -2
  223. package/templates/app/codegen-lite/scripts/codegen.mjs +6 -1
  224. package/templates/app/codegen-prelude.mjs.partial +8 -0
  225. package/templates/app/gitignore +3 -0
  226. package/templates/app/gitignore-lite +3 -0
  227. package/templates/app/package-scripts/package.json +1 -0
  228. package/templates/app/package-scripts/supabase.json +7 -0
  229. package/templates/app/package-scripts-lite/package.json +1 -7
  230. package/templates/app/skeleton/.editorconfig +1 -0
  231. package/templates/app/skeleton/.gitattributes +7 -1
  232. package/templates/app/skeleton/.github/dependabot.yml +17 -0
  233. package/templates/app/skeleton/.nvmrc +1 -1
  234. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +134 -1
  235. package/templates/app/skeleton/.starcistacks/dev/README.md +16 -0
  236. package/templates/app/skeleton/.starcistacks/dev/environment.json +5 -0
  237. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +71 -0
  238. package/templates/app/skeleton/.starcistacks/dev/runtime/env/KEYS.md +14 -0
  239. package/templates/app/skeleton/.starcistacks/production/README.md +37 -0
  240. package/templates/app/skeleton/.starcistacks/production/environment.json +5 -0
  241. package/templates/app/skeleton/.starcistacks/production/infra/swarm/Caddyfile +15 -0
  242. package/templates/app/skeleton/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
  243. package/templates/app/skeleton/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
  244. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
  245. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/images.lock.yaml +9 -0
  246. package/templates/app/skeleton/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
  247. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +142 -0
  248. package/templates/app/skeleton/.starcistacks/production/runtime/env/KEYS.md +14 -0
  249. package/templates/app/skeleton/README.md +6 -6
  250. package/templates/app/skeleton/scripts/codegen.mjs +1 -2
  251. package/templates/app/skeleton-lite/.editorconfig +16 -0
  252. package/templates/app/skeleton-lite/.gitattributes +8 -0
  253. package/templates/app/skeleton-lite/.github/dependabot.yml +17 -0
  254. package/templates/app/skeleton-lite/.nvmrc +1 -0
  255. package/templates/app/skeleton-lite/.sops.yaml +3 -2
  256. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +178 -1
  257. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +20 -0
  258. package/templates/app/skeleton-lite/.starcistacks/dev/environment.json +2 -1
  259. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +16 -0
  260. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +11 -0
  261. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +7 -0
  262. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +6 -0
  263. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +8 -0
  264. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +6 -0
  265. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +120 -0
  266. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -0
  267. package/templates/app/skeleton-lite/.starcistacks/production/README.md +38 -0
  268. package/templates/app/skeleton-lite/.starcistacks/production/environment.json +6 -0
  269. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/Caddyfile +11 -0
  270. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/owner-actions.md +11 -0
  271. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/recovery/restore-proof.md +7 -0
  272. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/current.yaml +6 -0
  273. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/images.lock.yaml +8 -0
  274. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/releases/previous.yaml +6 -0
  275. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +121 -0
  276. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +21 -0
  277. package/templates/app/skeleton-lite/.starciwork/brand/index.yaml +12 -0
  278. package/templates/app/skeleton-lite/README.md +5 -3
  279. package/templates/app/skeleton-lite/supabase/config.toml +8 -1
  280. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +15 -6
  281. package/templates/app/upgrade-full/.starcistacks/dev/README.md +13 -7
  282. package/templates/be/image/worker/Dockerfile +1 -1
  283. package/templates/be/inbox/database-client.spec.ts.tpl +17 -18
  284. package/templates/be/inbox/database-client.ts.tpl +12 -11
  285. package/templates/be/inbox/delivery-recorded.event.ts.tpl +36 -0
  286. package/templates/be/inbox/event-index.ts.tpl +1 -0
  287. package/templates/be/inbox/events.json.tpl +10 -0
  288. package/templates/be/inbox/migration.sql.tpl +1 -0
  289. package/templates/be/inbox/platform/event-bus/event-bus.contracts.ts.tpl +42 -0
  290. package/templates/be/inbox/platform/event-bus/event-bus.decorators.ts.tpl +9 -0
  291. package/templates/be/inbox/platform/event-bus/event-bus.module-definition.ts.tpl +6 -0
  292. package/templates/be/inbox/platform/event-bus/event-bus.module.ts.tpl +19 -0
  293. package/templates/be/inbox/platform/event-bus/event-bus.options.ts.tpl +2 -0
  294. package/templates/be/inbox/platform/event-bus/event-bus.port.ts.tpl +16 -0
  295. package/templates/be/inbox/platform/event-bus/event-bus.service.spec.ts.tpl +45 -0
  296. package/templates/be/inbox/platform/event-bus/event-bus.service.ts.tpl +15 -0
  297. package/templates/be/inbox/platform/event-bus/index.ts.tpl +5 -0
  298. package/templates/be/inbox/platform/event-bus/persistence/event-bus.sql.ts.tpl +5 -0
  299. package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +9 -0
  300. package/templates/be/inbox/platform/inbox/inbox.module-definition.ts.tpl +6 -0
  301. package/templates/be/inbox/platform/inbox/inbox.module.ts.tpl +19 -0
  302. package/templates/be/inbox/platform/inbox/inbox.options.ts.tpl +2 -0
  303. package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +10 -0
  304. package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +41 -0
  305. package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +19 -0
  306. package/templates/be/inbox/platform/inbox/index.ts.tpl +3 -0
  307. package/templates/be/inbox/platform/inbox/persistence/inbox.sql.ts.tpl +7 -0
  308. package/templates/be/inbox/platform/migration.sql.tpl +48 -0
  309. package/templates/be/inbox/service.spec.ts.tpl +42 -9
  310. package/templates/be/inbox/service.ts.tpl +23 -8
  311. package/templates/be/inbox/webhook-door.spec.ts.tpl +1 -1
  312. package/templates/be/inbox/webhook-door.ts.tpl +2 -2
  313. package/templates/be/patterns/api/feature.module-definition.ts.tpl +6 -0
  314. package/templates/be/patterns/api/feature.module.ts.tpl +2 -1
  315. package/templates/be/patterns/api/feature.options.ts.tpl +2 -0
  316. package/templates/be/patterns/api/graphql.module-definition.ts.tpl +6 -0
  317. package/templates/be/patterns/api/graphql.module.ts.tpl +3 -2
  318. package/templates/be/patterns/api/graphql.options.ts.tpl +2 -0
  319. package/templates/be/patterns/cli/group.module-definition.ts.tpl +6 -0
  320. package/templates/be/patterns/cli/group.module.ts.tpl +2 -1
  321. package/templates/be/patterns/cli/group.options.ts.tpl +2 -0
  322. package/templates/be/patterns/event-bus/platform/errors/event-bus.error.ts.tpl +0 -3
  323. package/templates/be/patterns/event-bus/platform/messages/event-bus.messages.ts.tpl +0 -2
  324. package/templates/be/patterns/jobs/job.module-definition.ts.tpl +6 -0
  325. package/templates/be/patterns/jobs/job.module.ts.tpl +2 -1
  326. package/templates/be/patterns/jobs/job.options.ts.tpl +2 -0
  327. package/templates/be/patterns/jobs/job.queue.module-definition.ts.tpl +6 -0
  328. package/templates/be/patterns/jobs/job.queue.module.ts.tpl +6 -3
  329. package/templates/be/patterns/jobs/job.queue.options.ts.tpl +2 -0
  330. package/templates/be/patterns/jobs/platform/fenced.processor.spec.ts.tpl +3 -1
  331. package/templates/be/patterns/outbox/platform/outbox-relay.policy.spec.ts.tpl +25 -9
  332. package/templates/be/patterns/queues/platform/queue.config.ts.tpl +1 -1
  333. package/templates/be/patterns/queues/platform/queue.index.ts.tpl +1 -1
  334. package/templates/be/patterns/queues/platform/queue.module-definition.ts.tpl +2 -2
  335. package/templates/be/patterns/queues/platform/queue.module.ts.tpl +11 -0
  336. package/templates/be/patterns/queues/platform/queue.options.ts.tpl +10 -0
  337. package/templates/be/patterns/queues/queue.module-definition.ts.tpl +6 -0
  338. package/templates/be/patterns/queues/queue.module.ts.tpl +2 -1
  339. package/templates/be/patterns/queues/queue.options.ts.tpl +2 -0
  340. package/templates/be/patterns/reactors/message.module-definition.ts.tpl +6 -0
  341. package/templates/be/patterns/reactors/message.module.ts.tpl +6 -3
  342. package/templates/be/patterns/reactors/message.options.ts.tpl +2 -0
  343. package/templates/be/patterns/reactors/reactor.module-definition.ts.tpl +6 -0
  344. package/templates/be/patterns/reactors/reactor.module.ts.tpl +2 -1
  345. package/templates/be/patterns/reactors/reactor.options.ts.tpl +2 -0
  346. package/templates/be/patterns/realtime/channel.gateway.spec.ts.tpl +188 -0
  347. package/templates/be/patterns/realtime/channel.gateway.ts.tpl +22 -0
  348. package/templates/be/patterns/realtime/channel.graphql.module-definition.ts.tpl +6 -0
  349. package/templates/be/patterns/realtime/channel.graphql.module.ts.tpl +2 -1
  350. package/templates/be/patterns/realtime/channel.graphql.options.ts.tpl +2 -0
  351. package/templates/be/patterns/realtime/channel.websocket.module-definition.ts.tpl +6 -0
  352. package/templates/be/patterns/realtime/channel.websocket.module.ts.tpl +110 -0
  353. package/templates/be/patterns/realtime/channel.websocket.options.ts.tpl +7 -0
  354. package/templates/be/patterns/saga/saga.message.module-definition.ts.tpl +6 -0
  355. package/templates/be/patterns/saga/saga.message.module.ts.tpl +6 -3
  356. package/templates/be/patterns/saga/saga.message.options.ts.tpl +2 -0
  357. package/templates/be/patterns/saga/saga.module-definition.ts.tpl +6 -0
  358. package/templates/be/patterns/saga/saga.module.ts.tpl +2 -1
  359. package/templates/be/patterns/saga/saga.options.ts.tpl +2 -0
  360. package/templates/be/patterns/webhooks/webhook.http.module-definition.ts.tpl +6 -0
  361. package/templates/be/patterns/webhooks/webhook.http.module.ts.tpl +2 -1
  362. package/templates/be/patterns/webhooks/webhook.http.options.ts.tpl +2 -0
  363. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +80 -26
  364. package/templates/be/platform/graphql/localize-errors.mapper.ts +2 -5
  365. package/templates/be/skeleton/apps/__app__/src/app.module.ts +11 -9
  366. package/templates/be/skeleton/apps/__app__/src/compose/api.ts +4 -0
  367. package/templates/be/skeleton/apps/__app__/src/compose/domain.ts +11 -0
  368. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +2 -0
  369. package/templates/be/skeleton/apps/__app__/src/compose/system.ts +5 -0
  370. package/templates/be/skeleton/apps/__app__/src/compose/webhooks.ts +4 -0
  371. package/templates/be/skeleton/apps/__app__/src/main.ts +15 -1
  372. package/templates/be/skeleton/apps/cli/src/app.module.ts +3 -13
  373. package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -14
  374. package/templates/be/skeleton/apps/cli/src/compose/cli.ts +5 -0
  375. package/templates/be/skeleton/apps/cli/src/compose/platform.ts +24 -0
  376. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.contracts.ts +2 -0
  377. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.handler.ts +22 -0
  378. package/templates/be/skeleton/src/features/api/system-health/application/check-readiness.query.ts +11 -0
  379. package/templates/be/skeleton/src/features/api/system-health/system-health.module-definition.ts +6 -0
  380. package/templates/be/skeleton/src/features/api/system-health/system-health.module.ts +5 -3
  381. package/templates/be/skeleton/src/features/api/system-health/system-health.options.ts +2 -0
  382. package/templates/be/skeleton/src/features/api/system-health/transport/http/ready.controller.ts +19 -0
  383. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module-definition.ts +6 -0
  384. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.module.ts +8 -3
  385. package/templates/be/skeleton/src/features/api/system-health/transport/http/system-health-http.options.ts +2 -0
  386. package/templates/be/skeleton/src/features/cli/cli.module-definition.ts +6 -0
  387. package/templates/be/skeleton/src/features/cli/cli.module.ts +3 -2
  388. package/templates/be/skeleton/src/features/cli/cli.options.ts +2 -0
  389. package/templates/be/skeleton/src/features/cli/migrate/migrate.module-definition.ts +6 -0
  390. package/templates/be/skeleton/src/features/cli/migrate/migrate.module.ts +2 -1
  391. package/templates/be/skeleton/src/features/cli/migrate/migrate.options.ts +2 -0
  392. package/templates/be/skeleton/src/features/cli/seed/seed.module-definition.ts +6 -0
  393. package/templates/be/skeleton/src/features/cli/seed/seed.module.ts +2 -1
  394. package/templates/be/skeleton/src/features/cli/seed/seed.options.ts +2 -0
  395. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.spec.ts +5 -6
  396. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.spec.ts +56 -4
  397. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +36 -5
  398. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.spec.ts +39 -8
  399. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +14 -8
  400. package/templates/be/skeleton/src/modules/domain/identity/credential.contracts.ts +26 -0
  401. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.spec.ts +119 -0
  402. package/templates/be/skeleton/src/modules/domain/identity/credential.guard.ts +43 -0
  403. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +16 -0
  404. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +18 -1
  405. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +22 -4
  406. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +8 -2
  407. package/templates/be/skeleton/src/modules/domain/identity/index.ts +1 -0
  408. package/templates/be/skeleton/src/modules/platform/database/connection-source.client.spec.ts +11 -4
  409. package/templates/be/skeleton/src/modules/platform/database/database-probe.service.spec.ts +32 -0
  410. package/templates/be/skeleton/src/modules/platform/database/database-probe.service.ts +14 -0
  411. package/templates/be/skeleton/src/modules/platform/database/database.module.ts +7 -1
  412. package/templates/be/skeleton/src/modules/platform/database/index.ts +2 -0
  413. package/templates/be/skeleton/src/modules/platform/database/primary.decorators.ts +8 -0
  414. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +25 -6
  415. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +16 -6
  416. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.spec.ts +130 -0
  417. package/templates/be/skeleton/src/modules/platform/http-security/edge-security.service.ts +58 -0
  418. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +3 -0
  419. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +16 -4
  420. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -10
  421. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +3 -1
  422. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +2 -0
  423. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +5 -0
  424. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +12 -5
  425. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +13 -8
  426. package/templates/be/skeleton/src/modules/platform/http-security/readiness.contracts.ts +5 -0
  427. package/templates/be/skeleton/src/modules/platform/http-security/readiness.decorators.ts +7 -0
  428. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.spec.ts +86 -0
  429. package/templates/be/skeleton/src/modules/platform/http-security/readiness.service.ts +39 -0
  430. package/templates/be/skeleton/src/modules/platform/http-security/webhook-signature.service.spec.ts +139 -131
  431. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +1 -1
  432. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +91 -67
  433. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +5 -4
  434. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +0 -2
  435. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.spec.ts +20 -6
  436. package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +16 -4
  437. package/templates/be/skeleton/src/modules/platform/retry/index.ts +3 -0
  438. package/templates/be/skeleton/src/modules/platform/retry/retry.decorators.ts +9 -0
  439. package/templates/be/skeleton/src/modules/platform/retry/retry.module-definition.ts +7 -0
  440. package/templates/be/skeleton/src/modules/platform/retry/retry.module.ts +19 -0
  441. package/templates/be/skeleton/src/modules/platform/retry/retry.options.ts +2 -0
  442. package/templates/be/skeleton/src/modules/platform/retry/retry.port.ts +11 -0
  443. package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.spec.ts +36 -0
  444. package/templates/be/skeleton/src/modules/platform/retry/system-timer.service.ts +10 -0
  445. package/templates/be/skeleton/src/tests/contract/http-security/http-security.contract-spec.ts +54 -0
  446. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +14 -18
  447. package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +4 -0
  448. package/templates/be/skeleton-lite/apps/api/src/compose/domain.ts +10 -0
  449. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +16 -0
  450. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +2 -0
  451. package/templates/be/skeleton-lite/apps/api/src/compose/system.ts +5 -0
  452. package/templates/be/skeleton-lite/apps/api/src/compose/webhooks.ts +4 -0
  453. package/templates/be/skeleton-lite/apps/api/src/main.ts +2 -3
  454. package/templates/be/skeleton-lite/apps/cli/src/compose/platform.ts +12 -0
  455. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +71 -5
  456. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.ts +29 -2
  457. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +68 -15
  458. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +14 -9
  459. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.contracts.ts +9 -2
  460. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +16 -1
  461. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +19 -4
  462. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.options.ts +5 -1
  463. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -1
  464. package/templates/be/skeleton-lite/src/modules/integrations/supabase/errors/supabase.error.ts +0 -3
  465. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +17 -5
  466. package/templates/be/skeleton-lite/src/modules/platform/database/database.module.ts +24 -13
  467. package/templates/be/skeleton-lite/src/modules/platform/database/database.port.ts +5 -0
  468. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +16 -0
  469. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +3 -0
  470. package/templates/be/skeleton-lite/src/modules/platform/http-security/http-security.module.ts +5 -2
  471. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +4 -1
  472. package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
  473. package/templates/be/skeleton-lite/src/modules/platform/logging/logging.log-events.ts +7 -0
  474. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +29 -0
  475. package/templates/be/skeleton-lite/src/tests/fixtures/builders/cli.builder.ts +16 -0
  476. package/templates/be/table/api/feature.module-definition.ts.tpl +6 -0
  477. package/templates/be/table/api/feature.module.ts.tpl +2 -1
  478. package/templates/be/table/api/feature.options.ts.tpl +2 -0
  479. package/templates/be/table/api/graphql.module-definition.ts.tpl +6 -0
  480. package/templates/be/table/api/graphql.module.ts.tpl +3 -2
  481. package/templates/be/table/api/graphql.options.ts.tpl +2 -0
  482. package/templates/be/table/migration.sql.tpl +1 -0
  483. package/templates/be/upgrade-full/apps/cli/src/app.module.ts +4 -6
  484. package/templates/be/upgrade-full/apps/cli/src/cli.env.ts +7 -0
  485. package/templates/be/upgrade-full/apps/cli/src/compose/cli.ts +5 -0
  486. package/templates/be/upgrade-full/apps/cli/src/compose/platform.ts +9 -0
  487. package/templates/be/upgrade-full/apps/cli/src/main.ts +3 -0
  488. package/templates/be/upgrade-full/src/features/cli/cli.module.ts +3 -2
  489. package/templates/fe/auth/supabase-client.ts.tpl +34 -0
  490. package/templates/fe/skeleton/apps/__app__/src/app/auth/callback/route.ts +11 -0
  491. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/index.tsx +6 -1
  492. package/templates/fe/skeleton/apps/__app__/src/components/leaves/Icon/index.tsx +4 -0
  493. package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/component.tsx +31 -0
  494. package/templates/fe/skeleton/apps/__app__/src/features/overlays/AppGuideOverlay/index.tsx +24 -0
  495. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -1
  496. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +3 -4
  497. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/index.ts +1 -0
  498. package/templates/fe/skeleton/apps/__app__/src/hooks/zustand/appGuide/store.ts +15 -0
  499. package/templates/fe/skeleton/apps/__app__/src/modules/auth/pkce-client.ts +18 -0
  500. package/templates/fe/skeleton/apps/__app__/src/modules/auth/server.ts +19 -0
  501. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +7 -1
  502. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/en.json +57 -0
  503. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +6 -0
  504. package/templates/fe/skeleton/apps/landing/src/modules/i18n/messages/en.json +31 -0
  505. package/templates/fe/skeleton/packages/__project__-i18n/src/routing.ts +1 -1
  506. package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -0
  507. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +1 -34
  508. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +7 -1
  509. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +57 -0
  510. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +30 -24
  511. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/routing.ts +1 -1
  512. package/templates/fe/transport/graphql/clients/clients/auth.ts +1 -1
  513. package/templates/fe/transport/graphql/clients/links/locale.ts +2 -2
  514. package/upgrade/check.mjs +3 -1
  515. package/upgrade/index.mjs +53 -40
@@ -53,69 +53,70 @@ export const fileOf = (directory, slug) => {
53
53
  return path.join(directory, `${slug}.enc`);
54
54
  };
55
55
 
56
- /** Seal `key = value` into the secret `slug`: an existing document keeps its other keys and its recipients. */
57
- export function writeSecret({ file, values, age, sops }) {
58
- for (const key of Object.keys(values)) validateKey(key);
59
- const held = claimFile(path.resolve(file));
60
- if (!held.ok)
61
- throw new SecretError(
62
- "the sealed secret is being updated; retry after that write settles",
63
- );
56
+ /** Build and validate a replacement envelope without publishing it. */
57
+ function sealSecretMap({ file, values, age, sops }) {
58
+ let map = Object.create(null);
59
+ let recipients = age;
60
+ if (fs.existsSync(file)) {
61
+ const envelope = envelopeOf(fs.readFileSync(file, "utf8"));
62
+ map = sops.decrypt(file, envelope.format);
63
+ if (!age.length) recipients = envelope.recipients;
64
+ }
65
+ validateMap(map);
66
+ map = { ...map, ...values };
67
+ const sealed = sops.seal({ inputType: "dotenv", plaintext: plaintextOf("dotenv", map), recipients, filenameOverride: file });
68
+ let parsed;
69
+ try { parsed = envelopeOf(sealed); }
70
+ catch { throw new SecretError("sops returned an invalid sealed document"); }
71
+ if (!isSopsEnvelope(sealed) || parsed.keys.length !== Object.keys(map).length || !parsed.keys.every((key) => Object.hasOwn(map, key)))
72
+ throw new SecretError("sops returned an invalid sealed document");
73
+ return sealed;
74
+ }
75
+
76
+ /** Publish through an explicit file owner, or the canonical atomic replace. */
77
+ function publishSealed(file, sealed, write) {
78
+ if (write) { write(file, sealed); return; }
79
+ fs.mkdirSync(path.dirname(file), { recursive: true });
80
+ const temporary = path.join(path.dirname(file), "." + path.basename(file) + "-" + randomUUID() + ".tmp");
64
81
  try {
65
- const format = "dotenv";
66
- let map = Object.create(null);
67
- let recipients = age;
68
- if (fs.existsSync(file)) {
69
- const text = fs.readFileSync(file, "utf8");
70
- const envelope = envelopeOf(text);
71
- map = sops.decrypt(file, envelope.format);
72
- if (!age.length) recipients = envelope.recipients;
73
- }
74
- validateMap(map);
75
- map = { ...map, ...values };
76
- const sealed = sops.seal({
77
- inputType: format,
78
- plaintext: plaintextOf(format, map),
79
- recipients,
80
- filenameOverride: file,
81
- });
82
- let parsed;
83
- try {
84
- parsed = envelopeOf(sealed);
85
- } catch {
86
- throw new SecretError("sops returned an invalid sealed document");
87
- }
88
- if (
89
- !isSopsEnvelope(sealed) ||
90
- parsed.keys.length !== Object.keys(map).length ||
91
- !parsed.keys.every((key) => Object.hasOwn(map, key))
92
- )
93
- throw new SecretError("sops returned an invalid sealed document");
94
- fs.mkdirSync(path.dirname(file), { recursive: true });
95
- const temporary = path.join(
96
- path.dirname(file),
97
- `.${path.basename(file)}-${randomUUID()}.tmp`,
98
- );
99
- try {
100
- fs.writeFileSync(temporary, sealed, {
101
- flag: "wx",
102
- mode: 0o600,
103
- flush: true,
104
- });
105
- fs.renameSync(temporary, file);
106
- } finally {
107
- if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
108
- }
82
+ fs.writeFileSync(temporary, sealed, { flag: "wx", mode: 0o600, flush: true });
83
+ fs.renameSync(temporary, file);
109
84
  } finally {
110
- held.release();
85
+ if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
111
86
  }
112
87
  }
113
88
 
89
+ function underClaim(file, action) {
90
+ const held = claimFile(path.resolve(file));
91
+ if (!held.ok)
92
+ throw new SecretError("the sealed secret is being updated; retry after that write settles");
93
+ try {
94
+ action();
95
+ } finally { held.release(); }
96
+ }
97
+
98
+ /** Replace one existing sealed member with an already validated envelope: same claim and atomic publish as set. */
99
+ export const replaceSealed = (file, sealed) => underClaim(file, () => publishSealed(file, sealed));
100
+
101
+ /** Delete one sealed member under the writer claim. */
102
+ export const deleteSealed = (file) => underClaim(file, () => fs.unlinkSync(file));
103
+
104
+ /** Seal named values; a supplied writer owns its own claim and rollback (the fresh-app scaffold). */
105
+ export function writeSecret({ file, values, age, sops, write }) {
106
+ for (const key of Object.keys(values)) validateKey(key);
107
+ const held = write ? { ok: true, release() {} } : claimFile(path.resolve(file));
108
+ if (!held.ok)
109
+ throw new SecretError("the sealed secret is being updated; retry after that write settles");
110
+ try {
111
+ publishSealed(file, sealSecretMap({ file, values, age, sops }), write);
112
+ } finally { held.release(); }
113
+ }
114
+
114
115
  /** Update named keys through the same canonical sealed-secret owner used by the CLI. */
115
116
  export function setSecretValues(
116
117
  repoRoot,
117
118
  { slug, values, env = null },
118
- { sops = null, age = [], processEnv = process.env } = {},
119
+ { sops = null, age = [], processEnv = process.env, write } = {},
119
120
  ) {
120
121
  const directory = secretsDirectory(repoRoot, env);
121
122
  const file = fileOf(directory, slug);
@@ -134,6 +135,7 @@ export function setSecretValues(
134
135
  values,
135
136
  age,
136
137
  sops: sops ?? defaultSops(processEnv, repoRoot),
138
+ write,
137
139
  });
138
140
  return { file, via: "sealed-secret" };
139
141
  }
@@ -1,6 +1,8 @@
1
- // secret.mjs - app secret init|set|get|materialize|list: sealed custody and private KEY files.
1
+ // secret.mjs - app secret init|set|get|unset|rename|remove|materialize|list|verify: sealed custody and private KEY files.
2
2
  // SOPS plaintext travels on stdin and stays in memory; normal CLI output contains names and local file paths only.
3
3
  // Every verb runs at the app root or --cwd. Exit 0 done, 2 refusal; --reveal is the only plaintext stdout path.
4
+ // unset, rename and remove edit one sealed member and are dry runs until --execute (scripts/hfs/secret-edit.mjs).
5
+ // verify decrypts each member in memory and names only keys and violated shapes, never a value (R249).
4
6
  import fs from "node:fs";
5
7
  import path from "node:path";
6
8
  import { randomBytes } from "node:crypto";
@@ -11,11 +13,14 @@ import { defaultSops } from "./secret-sops.mjs";
11
13
  import { fileOf, secretsDirectory, writeSecret } from "./secret-store.mjs";
12
14
  import { getSecret, listSecrets, materializeSecrets } from "./secret-output.mjs";
13
15
  import { SecretError, publicStorage, secretErrorMessage } from "./secret-errors.mjs";
16
+ import { editSecret, validateEditNames } from "./secret-edit.mjs";
17
+ import { verifySecrets } from "./rules/env-value-shape.mjs";
14
18
 
15
19
  export { envelopeOf, formatOf, plaintextOf } from "./secret-document.mjs";
16
20
  export { setSecretValues } from "./secret-store.mjs";
17
21
 
18
- const VERBS = new Set(["init", "list", "get", "set", "materialize"]);
22
+ const VERBS = new Set(["init", "list", "get", "set", "unset", "rename", "remove", "materialize", "verify"]);
23
+ const EDIT_VERBS = new Set(["unset", "rename", "remove"]);
19
24
 
20
25
  function valueOption(opts, flag, value) {
21
26
  if (value === undefined || value.startsWith("--"))
@@ -32,7 +37,7 @@ function parse(argv) {
32
37
  const [flag, inline] = arg.split(/=(.*)/su);
33
38
  if (["--cwd", "--generate"].includes(flag)) {
34
39
  valueOption(opts, flag, inline ?? argv[++i]);
35
- } else if (["--to-file", "--reveal"].includes(flag)) {
40
+ } else if (["--to-file", "--reveal", "--execute", "--force-nonempty"].includes(flag)) {
36
41
  if (inline !== undefined && !["true", "false"].includes(inline))
37
42
  throw new SecretError(`${flag} expects true or false`);
38
43
  opts[flag.slice(2)] = inline !== "false";
@@ -55,9 +60,19 @@ function usageArguments(verb) {
55
60
  if (verb === "init") return "";
56
61
  if (verb === "set") return "<env> <slug> <KEY> [--generate <bytes>]";
57
62
  if (verb === "get") return "<env> <slug> [KEY] --to-file [--reveal]";
63
+ if (verb === "unset") return "<env> <slug> <KEY> [--execute]";
64
+ if (verb === "rename") return "<env> <slug> <OLD> <NEW> [--execute]";
65
+ if (verb === "remove") return "<env> <slug> [--execute] [--force-nonempty]";
58
66
  return "<env>";
59
67
  }
60
68
 
69
+ function editOptionsFor(verb, opts) {
70
+ if (opts.execute !== undefined && !EDIT_VERBS.has(verb))
71
+ throw new SecretError("--execute is only valid for unset, rename and remove");
72
+ if (opts["force-nonempty"] !== undefined && verb !== "remove")
73
+ throw new SecretError("--force-nonempty is only valid for remove");
74
+ }
75
+
61
76
  function outputOptionsFor(verb, opts) {
62
77
  if (
63
78
  (opts["to-file"] !== undefined || opts.reveal !== undefined) &&
@@ -74,8 +89,12 @@ function optionsFor(verb, opts) {
74
89
  init: [0, 0],
75
90
  list: [1, 1],
76
91
  materialize: [1, 1],
92
+ verify: [1, 1],
77
93
  set: [3, 3],
78
94
  get: [2, 3],
95
+ unset: [3, 3],
96
+ rename: [4, 4],
97
+ remove: [2, 2],
79
98
  }[verb];
80
99
  if (count < bounds[0] || count > bounds[1])
81
100
  throw new SecretError(
@@ -84,6 +103,8 @@ function optionsFor(verb, opts) {
84
103
  if (opts.generate !== undefined && verb !== "set")
85
104
  throw new SecretError("--generate is only valid for set");
86
105
  outputOptionsFor(verb, opts);
106
+ editOptionsFor(verb, opts);
107
+ if (EDIT_VERBS.has(verb)) validateEditNames(verb, opts.positional.slice(2));
87
108
  if (verb === "set") validateKey(opts.positional[2]);
88
109
  if (verb === "get" && opts.positional[2] !== undefined)
89
110
  validateKey(opts.positional[2]);
@@ -130,7 +151,7 @@ export function secretMain(
130
151
  try {
131
152
  if (!VERBS.has(verb))
132
153
  throw new SecretError(
133
- "usage: starci app secret init | set <env> <slug> <KEY> | get <env> <slug> [KEY] --to-file | materialize <env> | list <env>",
154
+ "usage: starci app secret init | set <env> <slug> <KEY> | get <env> <slug> [KEY] --to-file | unset <env> <slug> <KEY> | rename <env> <slug> <OLD> <NEW> | remove <env> <slug> | materialize <env> | list <env> | verify <env>",
134
155
  );
135
156
  const opts = parse(rest);
136
157
  optionsFor(verb, opts);
@@ -142,7 +163,15 @@ export function secretMain(
142
163
  const [environment, slug, key] = opts.positional;
143
164
  const directory = secretsDirectory(repoRoot, environment);
144
165
  if (verb === "list") return listSecrets(directory, stdout);
145
- const seam = sops ?? defaultSops(env, repoRoot, platform, tools);
166
+ const open = () => sops ?? defaultSops(env, repoRoot, platform, tools);
167
+ if (EDIT_VERBS.has(verb))
168
+ return editSecret(verb, {
169
+ directory, environment, slug, names: opts.positional.slice(2),
170
+ execute: opts.execute === true, force: opts["force-nonempty"] === true, sops: open, stdout,
171
+ });
172
+ const seam = open();
173
+ if (verb === "verify")
174
+ return verifySecrets({ repoRoot, directory, environment, sops: seam, stdout });
146
175
  const output = { repoRoot, environment, env, platform, stdout };
147
176
  if (verb === "materialize")
148
177
  return materializeSecrets(directory, seam, output);
@@ -2,9 +2,14 @@
2
2
  // owner units, the layer order and the public entry of an owner. Cycles are a graph property of the architecture check, not of one edge.
3
3
  import { RUNTIME_KIND } from './manifest-shape.mjs';
4
4
  import { classifyIn, ownerIn, tierIn } from './slot-classify.mjs';
5
+ import { braceVariants, globExpression } from '../lib/glob.mjs';
5
6
 
6
7
  const isEntryFile = (name) => name === 'index.ts' || name === 'index.tsx';
7
8
 
9
+ function declaredEntry(slot, relative) {
10
+ return (slot.entries ?? []).some(entry => braceVariants(entry).some(pattern => globExpression(pattern).test(relative)));
11
+ }
12
+
8
13
  function unownedPathProblem(from, to) {
9
14
  for (const side of [from, to]) {
10
15
  if (side.status === 'no-slot') return { allowed: false, reason: 'unowned', code: side.code, path: side.path, nearest: side.nearest };
@@ -39,12 +44,22 @@ function privateEntryProblem({ repo, byId }, to, toOwner) {
39
44
  if (toOwner && repo.kind !== RUNTIME_KIND) {
40
45
  const relative = to.path === toOwner.root ? '' : to.path.slice(toOwner.root.length + 1);
41
46
  const ownerSlot = byId.get(toOwner.slot);
42
- const entry = isEntryFile(relative) || (byId.get(to.slot).entries ?? []).includes(to.path.slice(to.root.length + 1)) || (ownerSlot.tier === 'package' && relative === 'src/index.ts') || (ownerSlot.tier === 'app' && relative === 'app.module.ts');
47
+ const entry = isEntryFile(relative) || declaredEntry(byId.get(to.slot), to.path.slice(to.root.length + 1)) || (ownerSlot.tier === 'package' && relative === 'src/index.ts') || (ownerSlot.tier === 'app' && relative === 'app.module.ts');
43
48
  if (!entry) return { allowed: false, reason: 'notPublicEntry', owner: toOwner.root, path: to.path };
44
49
  }
45
50
  return null;
46
51
  }
47
52
 
53
+ function tierImportProblem(scope, from, to, fromTier, toTier) {
54
+ const { mayImport, mayImportKinds, mayImportSlots } = scope.manifest.tiers[scope.profile][fromTier] ?? {};
55
+ if (!mayImport?.includes(toTier)) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport: mayImport ?? [] };
56
+ const kindRestriction = mayImportKinds?.[toTier];
57
+ if (kindRestriction && (!kindRestriction.from.includes(from.kind) || !kindRestriction.to.includes(to.kind))) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport, kindRestriction, fromKind: from.kind ?? null, toKind: to.kind ?? null };
58
+ const slotRestriction = mayImportSlots?.[toTier];
59
+ if (slotRestriction && !slotRestriction.from.includes(from.slot)) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport, slotRestriction, fromSlot: from.slot };
60
+ return null;
61
+ }
62
+
48
63
  /**
49
64
  * Whether `fromPath` may import `toPath`: {allowed, reason, ...}. Reasons: sameOwner, untiered, crossApp,
50
65
  * tierDirection, layerOrder, notPublicEntry, allowed, slotForbidden, slotNotEnabled, slotAmbiguous, and unowned (HFS_SLOT_UNDECLARED for the path no slot owns).
@@ -65,10 +80,8 @@ export function importAllowedIn(scope, fromPath, toPath) {
65
80
  const fromOwner = ownerIn(scope, from.path);
66
81
  const toOwner = ownerIn(scope, to.path);
67
82
  if (sameOwnerUnit(from, fromOwner) === sameOwnerUnit(to, toOwner)) return { allowed: true, reason: 'sameOwner' };
68
- const { mayImport, mayImportKinds } = scope.manifest.tiers[scope.profile][fromTier] ?? {};
69
- if (!mayImport?.includes(toTier)) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport: mayImport ?? [] };
70
- const kindRestriction = mayImportKinds?.[toTier];
71
- if (kindRestriction && (!kindRestriction.from.includes(from.kind) || !kindRestriction.to.includes(to.kind))) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport, kindRestriction, fromKind: from.kind ?? null, toKind: to.kind ?? null };
83
+ const tierProblem = tierImportProblem(scope, from, to, fromTier, toTier);
84
+ if (tierProblem) return tierProblem;
72
85
  const layerProblem = lowerLayerProblem(scope, from, to, fromTier, toTier, fromOwner, toOwner);
73
86
  if (layerProblem) return layerProblem;
74
87
  // A runtime owner is imported file by file (crossOwner of knowledge/hfs/runtime-slots.yaml): no public entry.
@@ -119,6 +119,7 @@ function sideDeclaration(manifest, declaration, name, edition, providers) {
119
119
  connections: Object.freeze((s.connections ?? []).map(connectionView)),
120
120
  providers,
121
121
  reads: Object.freeze([...(s.reads ?? [])]),
122
+ ...(s.extraPackages === undefined ? {} : { extraPackages: Object.freeze(s.extraPackages.map(entry => Object.freeze({ ...entry }))) }),
122
123
  manifestVersion: manifest.version,
123
124
  });
124
125
  }
@@ -0,0 +1,34 @@
1
+ // stack-config-kit.mjs - the small validators the deploy block (stack-deploy-config.mjs) and its ingress block (stack-ingress-config.mjs)
2
+ // share: a problem collector, closed field lists, pattern checks and the environment-relative file check.
3
+ import path from 'node:path';
4
+ import { isPlainObject as plain } from '../../engine/plain-object.mjs';
5
+
6
+ const BACKSLASH = String.fromCodePoint(92);
7
+
8
+ /** Collects problems with the path they concern; `problem(at, message)` is the only way to add one. */
9
+ export function collector() {
10
+ const problems = [];
11
+ return { problems, problem: (at, message) => { problems.push({ at, message }); } };
12
+ }
13
+
14
+ /** True for a mapping; reports every key outside `allowed` as "not a field of the <label> block". */
15
+ export function closedFields(c, at, value, allowed, label = 'deploy') {
16
+ if (!plain(value)) { c.problem(at, 'must be a mapping'); return false; }
17
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) c.problem(`${at}.${key}`, `is not a field of the ${label} block`);
18
+ return true;
19
+ }
20
+
21
+ /** True when `value` is a string matching `pattern`; otherwise reports it. */
22
+ export function patternField(c, at, value, pattern) {
23
+ if (typeof value !== 'string' || !pattern.test(value)) { c.problem(at, `must match ${pattern}`); return false; }
24
+ return true;
25
+ }
26
+
27
+ /** A path inside .starcistacks/<env>/ : relative, forward slashes, no `..`, and present in the tree. */
28
+ export function treeFile(c, at, value, probe) {
29
+ if (typeof value !== 'string' || value === '' || path.posix.isAbsolute(value) || value.includes(BACKSLASH) || path.posix.normalize(value).split('/').includes('..')) {
30
+ c.problem(at, 'must be a relative path inside the environment directory');
31
+ return;
32
+ }
33
+ if (!probe.exists(value)) c.problem(at, `${value} does not exist under the environment directory`);
34
+ }
@@ -0,0 +1,313 @@
1
+ // stack-deploy-config.mjs - the ONE reader of `environments.<env>.deploy` in .starcistacks/application-stacks.yaml (R294).
2
+ // `starci app stack ...` (packages/hfs/stack) normalises the block through it and `starci app check` (rules/stacks-deploy.mjs)
3
+ // judges the same block through it, so the verbs and the rule can never disagree. Pure given the block and a probe of the app tree.
4
+ import { isPlainObject as plain } from '../../engine/plain-object.mjs';
5
+ import { closedFields, collector, patternField, treeFile } from './stack-config-kit.mjs';
6
+ import { list } from '../lib/list.mjs';
7
+ import { normalizeIngress } from './stack-ingress-config.mjs';
8
+
9
+ export const DEPLOY_CONFIG_CODE = 'STACKS_DEPLOY_CONFIG_INVALID';
10
+
11
+ export const NAME = /^[a-z][a-z0-9-]*$/u;
12
+ export const KEY = /^[A-Z][A-Z0-9_]*$/u;
13
+ const VOLUME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u;
14
+ const HOST_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
15
+ const REMOTE_BASE = /^\/[A-Za-z0-9._/-]+$/u;
16
+ const HTTP_URL = /^https?:\/\/[^\s]+$/u;
17
+ const PINNED_HELPER_IMAGE = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}$)[0-9a-f]{64}$/u;
18
+ const ARGUMENT = /^[A-Za-z0-9_@%+=:,./-]+$/u;
19
+ const UPPER_ENV = KEY;
20
+
21
+ const FIELDS = {
22
+ deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'verify', 'jobs', 'tasks', 'backup', 'ingress', 'registry'],
23
+ remote: ['host', 'releasesBase', 'sealed'],
24
+ sealed: ['ops', 'sshKey', 'knownHosts'],
25
+ image: ['app', 'buildArgs'],
26
+ buildArgs: ['values', 'sealed'],
27
+ secrets: ['grants', 'settings'],
28
+ grant: ['service', 'filePointers', 'from'],
29
+ source: ['member', 'keys', 'required', 'fill'],
30
+ settings: ['member', 'keys'],
31
+ environment: ['files', 'values', 'settings'],
32
+ setting: ['key', 'default'],
33
+ config: ['name', 'file', 'env'],
34
+ verify: ['http', 'exec'],
35
+ http: ['name', 'url', 'method', 'body', 'status', 'json', 'contains', 'retry'],
36
+ exec: ['name', 'service', 'command', 'stdout'],
37
+ jobs: ['service', 'command'],
38
+ task: ['service', 'command'],
39
+ backup: ['image', 'keepDays', 'dir', 'volumes'],
40
+ backupVolume: ['name', 'volume'],
41
+ registry: ['host', 'visibility'],
42
+ };
43
+
44
+ const text = (value) => typeof value === 'string' && value.length > 0;
45
+
46
+ const nameField = (c, at, value, pattern = NAME) => patternField(c, at, value, pattern);
47
+
48
+ function stringList(c, at, value, itemPattern, { nonEmpty = false } = {}) {
49
+ if (!Array.isArray(value) || (nonEmpty && value.length === 0)) { c.problem(at, 'must be a list'); return []; }
50
+ value.forEach((item, index) => { if (typeof item !== 'string' || !itemPattern.test(item)) c.problem(`${at}[${index}]`, `must match ${itemPattern}`); });
51
+ return value;
52
+ }
53
+
54
+ function remote(c, block) {
55
+ if (block === undefined) return { host: 'vps', releasesBase: null, sealed: null };
56
+ if (!closedFields(c, 'deploy.remote', block, FIELDS.remote)) return { host: 'vps', releasesBase: null, sealed: null };
57
+ if (block.host !== undefined) nameField(c, 'deploy.remote.host', block.host, HOST_ALIAS);
58
+ if (block.releasesBase !== undefined && (!REMOTE_BASE.test(String(block.releasesBase)) || String(block.releasesBase).includes('..')))
59
+ c.problem('deploy.remote.releasesBase', 'must be an absolute path without ..');
60
+ let sealed = null;
61
+ if (block.sealed !== undefined && closedFields(c, 'deploy.remote.sealed', block.sealed, FIELDS.sealed)) {
62
+ for (const key of FIELDS.sealed) nameField(c, `deploy.remote.sealed.${key}`, block.sealed[key]);
63
+ sealed = { ...block.sealed };
64
+ }
65
+ return { host: block.host ?? 'vps', releasesBase: block.releasesBase ?? null, sealed };
66
+ }
67
+
68
+ function imageBuildArgs(c, at, block, members) {
69
+ const out = { values: {}, sealed: null };
70
+ if (block === undefined) return out;
71
+ if (!closedFields(c, at, block, FIELDS.buildArgs)) return out;
72
+ for (const [key, value] of Object.entries(block.values ?? {})) {
73
+ if (!KEY.test(key) || typeof value !== 'string') c.problem(`${at}.values.${key}`, 'must be KEY: "string"');
74
+ else out.values[key] = value;
75
+ }
76
+ if (block.sealed !== undefined && closedFields(c, `${at}.sealed`, block.sealed, FIELDS.settings)) {
77
+ memberRef(c, `${at}.sealed.member`, block.sealed.member, members);
78
+ out.sealed = { member: block.sealed.member, keys: stringList(c, `${at}.sealed.keys`, block.sealed.keys, KEY, { nonEmpty: true }) };
79
+ }
80
+ return out;
81
+ }
82
+
83
+ function memberRef(c, at, member, members) {
84
+ if (!nameField(c, at, member)) return;
85
+ if (members.size > 0 && !members.has(member)) c.problem(at, `sealed member ${member} has no .starcistacks/<env>/secrets/${member}.enc`);
86
+ }
87
+
88
+ function images(c, block, services, members) {
89
+ const out = {};
90
+ if (block === undefined) return out;
91
+ if (!plain(block)) { c.problem('deploy.images', 'must be a mapping of service to image'); return out; }
92
+ for (const [service, entry] of Object.entries(block)) {
93
+ const at = `deploy.images.${service}`;
94
+ nameField(c, at, service);
95
+ if (services.size > 0 && !services.has(service)) c.problem(at, `${service} is not a service of the stack source`);
96
+ if (!closedFields(c, at, entry, FIELDS.image)) continue;
97
+ nameField(c, `${at}.app`, entry.app);
98
+ out[service] = { app: entry.app, buildArgs: imageBuildArgs(c, `${at}.buildArgs`, entry.buildArgs, members) };
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function sourceEntry(c, at, entry, members) {
104
+ if (!closedFields(c, at, entry, FIELDS.source)) return null;
105
+ memberRef(c, `${at}.member`, entry.member, members);
106
+ const keys = entry.keys === undefined ? null : stringList(c, `${at}.keys`, entry.keys, KEY, { nonEmpty: true });
107
+ for (const flag of ['required', 'fill']) if (entry[flag] !== undefined && typeof entry[flag] !== 'boolean') c.problem(`${at}.${flag}`, 'must be true or false');
108
+ if (entry.required === true && keys === null) c.problem(`${at}.required`, 'needs an explicit keys list');
109
+ return { member: entry.member, keys, required: entry.required === true, fill: entry.fill === true };
110
+ }
111
+
112
+ function grants(c, block, services, members) {
113
+ const out = [];
114
+ list(block).forEach((grant, index) => {
115
+ const at = `deploy.secrets.grants[${index}]`;
116
+ if (!closedFields(c, at, grant, FIELDS.grant)) return;
117
+ nameField(c, `${at}.service`, grant.service);
118
+ if (services.size > 0 && !services.has(grant.service)) c.problem(`${at}.service`, `${grant.service} is not a service of the stack source`);
119
+ if (grant.filePointers !== undefined && typeof grant.filePointers !== 'boolean') c.problem(`${at}.filePointers`, 'must be true or false');
120
+ if (!Array.isArray(grant.from) || grant.from.length === 0) { c.problem(`${at}.from`, 'must list at least one sealed source'); return; }
121
+ const from = grant.from.map((entry, at2) => sourceEntry(c, `${at}.from[${at2}]`, entry, members)).filter(Boolean);
122
+ out.push({ service: grant.service, filePointers: grant.filePointers === true, from });
123
+ });
124
+ return out;
125
+ }
126
+
127
+ function secretSettings(c, block, members) {
128
+ if (block === undefined) return null;
129
+ if (!closedFields(c, 'deploy.secrets.settings', block, FIELDS.settings)) return null;
130
+ memberRef(c, 'deploy.secrets.settings.member', block.member, members);
131
+ return { member: block.member, keys: stringList(c, 'deploy.secrets.settings.keys', block.keys, KEY, { nonEmpty: true }) };
132
+ }
133
+
134
+ function secrets(c, block, services, members) {
135
+ if (block === undefined) return { grants: [], settings: null };
136
+ if (!closedFields(c, 'deploy.secrets', block, FIELDS.secrets)) return { grants: [], settings: null };
137
+ if (block.grants !== undefined && !Array.isArray(block.grants)) c.problem('deploy.secrets.grants', 'must be a list');
138
+ return { grants: grants(c, block.grants, services, members), settings: secretSettings(c, block.settings, members) };
139
+ }
140
+
141
+ function serviceEnvironment(c, at, entry, probe) {
142
+ const out = { files: [], values: {}, settings: {} };
143
+ if (!closedFields(c, at, entry, FIELDS.environment)) return out;
144
+ list(entry.files).forEach((file, index) => { treeFile(c, `${at}.files[${index}]`, file, probe); out.files.push(file); });
145
+ for (const [key, value] of Object.entries(entry.values ?? {})) {
146
+ if (!UPPER_ENV.test(key) || typeof value !== 'string') c.problem(`${at}.values.${key}`, 'must be KEY: "string"');
147
+ else out.values[key] = value;
148
+ }
149
+ for (const [key, setting] of Object.entries(entry.settings ?? {})) {
150
+ if (!UPPER_ENV.test(key)) { c.problem(`${at}.settings.${key}`, `the environment name must match ${UPPER_ENV}`); continue; }
151
+ if (!closedFields(c, `${at}.settings.${key}`, setting, FIELDS.setting) || !nameField(c, `${at}.settings.${key}.key`, setting.key, KEY)) continue;
152
+ out.settings[key] = { key: setting.key, default: setting.default === undefined ? null : String(setting.default) };
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function environmentBlock(c, block, services, probe) {
158
+ const out = {};
159
+ if (block === undefined) return out;
160
+ if (!plain(block)) { c.problem('deploy.environment', 'must be a mapping of service to environment'); return out; }
161
+ for (const [service, entry] of Object.entries(block)) {
162
+ if (services.size > 0 && !services.has(service)) c.problem(`deploy.environment.${service}`, `${service} is not a service of the stack source`);
163
+ out[service] = serviceEnvironment(c, `deploy.environment.${service}`, entry, probe);
164
+ }
165
+ return out;
166
+ }
167
+
168
+ function configs(c, block, probe) {
169
+ const out = [];
170
+ list(block).forEach((entry, index) => {
171
+ const at = `deploy.configs[${index}]`;
172
+ if (!closedFields(c, at, entry, FIELDS.config)) return;
173
+ nameField(c, `${at}.name`, entry.name);
174
+ nameField(c, `${at}.env`, entry.env, KEY);
175
+ treeFile(c, `${at}.file`, entry.file, probe);
176
+ out.push({ name: entry.name, file: entry.file, env: entry.env });
177
+ });
178
+ const seen = new Set();
179
+ for (const entry of out) { if (seen.has(entry.name)) c.problem('deploy.configs', `config name ${entry.name} is declared twice`); seen.add(entry.name); }
180
+ return out;
181
+ }
182
+
183
+ function httpChecks(c, block) {
184
+ return list(block).flatMap((entry, index) => {
185
+ const at = `deploy.verify.http[${index}]`;
186
+ if (!closedFields(c, at, entry, FIELDS.http)) return [];
187
+ if (!text(entry.name)) c.problem(`${at}.name`, 'must be a non-empty string');
188
+ if (!HTTP_URL.test(String(entry.url))) c.problem(`${at}.url`, 'must be an http(s) URL');
189
+ if (entry.method !== undefined && !['GET', 'POST'].includes(entry.method)) c.problem(`${at}.method`, 'must be GET or POST');
190
+ if (entry.status !== undefined && !(Number.isInteger(entry.status) && entry.status >= 100 && entry.status <= 599)) c.problem(`${at}.status`, 'must be an HTTP status code');
191
+ if (entry.json !== undefined && !plain(entry.json)) c.problem(`${at}.json`, 'must be a mapping of expected fields');
192
+ return [{ name: entry.name, url: entry.url, method: entry.method ?? 'GET', body: entry.body ?? null, status: entry.status ?? 200, json: entry.json ?? null, contains: entry.contains ?? null, retry: entry.retry === true }];
193
+ });
194
+ }
195
+
196
+ function command(c, at, value) {
197
+ const items = stringList(c, at, value, ARGUMENT, { nonEmpty: true });
198
+ return items;
199
+ }
200
+
201
+ function execChecks(c, block, services) {
202
+ return list(block).flatMap((entry, index) => {
203
+ const at = `deploy.verify.exec[${index}]`;
204
+ if (!closedFields(c, at, entry, FIELDS.exec)) return [];
205
+ if (!text(entry.name)) c.problem(`${at}.name`, 'must be a non-empty string');
206
+ nameField(c, `${at}.service`, entry.service);
207
+ if (services.size > 0 && !services.has(entry.service)) c.problem(`${at}.service`, `${entry.service} is not a service of the stack source`);
208
+ if (entry.stdout !== undefined) try { new RegExp(entry.stdout, 'u'); } catch { c.problem(`${at}.stdout`, 'must be a valid regular expression'); }
209
+ return [{ name: entry.name, service: entry.service, command: command(c, `${at}.command`, entry.command), stdout: entry.stdout ?? null }];
210
+ });
211
+ }
212
+
213
+ function verify(c, block, services) {
214
+ if (block === undefined) return { http: [], exec: [] };
215
+ if (!closedFields(c, 'deploy.verify', block, FIELDS.verify)) return { http: [], exec: [] };
216
+ return { http: httpChecks(c, block.http), exec: execChecks(c, block.exec, services) };
217
+ }
218
+
219
+ function jobs(c, block, services) {
220
+ if (block === undefined) return null;
221
+ if (!closedFields(c, 'deploy.jobs', block, FIELDS.jobs)) return null;
222
+ nameField(c, 'deploy.jobs.service', block.service);
223
+ if (services.size > 0 && !services.has(block.service)) c.problem('deploy.jobs.service', `${block.service} is not a service of the stack source`);
224
+ return { service: block.service, command: command(c, 'deploy.jobs.command', block.command) };
225
+ }
226
+
227
+ const TASK_NAME = /^[a-z][a-z0-9-]{0,40}$/u;
228
+
229
+ /** The one-off commands (`starci app stack task <name>`): name -> { service, command }, each run inside a running task of the service. */
230
+ function tasks(c, block, services) {
231
+ if (block === undefined) return {};
232
+ if (!plain(block)) { c.problem('deploy.tasks', 'must be a mapping'); return {}; }
233
+ const out = {};
234
+ for (const [name, entry] of Object.entries(block)) {
235
+ const at = `deploy.tasks.${name}`;
236
+ if (!TASK_NAME.test(name)) c.problem(at, `the task name must match ${TASK_NAME}`);
237
+ if (!closedFields(c, at, entry, FIELDS.task)) continue;
238
+ nameField(c, `${at}.service`, entry.service);
239
+ if (services.size > 0 && !services.has(entry.service)) c.problem(`${at}.service`, `${entry.service} is not a service of the stack source`);
240
+ out[name] = { service: entry.service, command: command(c, `${at}.command`, entry.command) };
241
+ }
242
+ return out;
243
+ }
244
+
245
+ const REGISTRY_HOST = /^[a-z0-9][a-z0-9.-]*(?::[0-9]{1,5})?$/u;
246
+ const VISIBILITY = ['public', 'private'];
247
+
248
+ /** The image registry: host (default ghcr.io) and the visibility the app's packages are meant to have (default public, the canon scaffold writes it and the app owns it afterwards). */
249
+ function registry(c, block) {
250
+ const fallback = { host: 'ghcr.io', visibility: 'public' };
251
+ if (block === undefined) return fallback;
252
+ if (!closedFields(c, 'deploy.registry', block, FIELDS.registry)) return fallback;
253
+ if (block.host !== undefined) patternField(c, 'deploy.registry.host', block.host, REGISTRY_HOST);
254
+ if (block.visibility !== undefined && !VISIBILITY.includes(block.visibility)) c.problem('deploy.registry.visibility', `must be one of ${VISIBILITY.join(', ')}`);
255
+ return { host: block.host ?? fallback.host, visibility: block.visibility ?? fallback.visibility };
256
+ }
257
+
258
+ function backup(c, block) {
259
+ if (block === undefined) return null;
260
+ if (!closedFields(c, 'deploy.backup', block, FIELDS.backup)) return null;
261
+ if (!PINNED_HELPER_IMAGE.test(String(block.image))) c.problem('deploy.backup.image', 'must be a digest-pinned image reference (no all-zero digest)');
262
+ if (block.keepDays !== undefined && !(Number.isInteger(block.keepDays) && block.keepDays >= 1 && block.keepDays <= 3650)) c.problem('deploy.backup.keepDays', 'must be a whole number of days from 1 to 3650');
263
+ if (block.dir !== undefined && (!REMOTE_BASE.test(String(block.dir)) || String(block.dir).includes('..'))) c.problem('deploy.backup.dir', 'must be an absolute path without ..');
264
+ const volumes = list(block.volumes).flatMap((entry, index) => {
265
+ const at = `deploy.backup.volumes[${index}]`;
266
+ if (!closedFields(c, at, entry, FIELDS.backupVolume)) return [];
267
+ nameField(c, `${at}.name`, entry.name);
268
+ nameField(c, `${at}.volume`, entry.volume, VOLUME);
269
+ return [{ name: entry.name, volume: entry.volume }];
270
+ });
271
+ if (volumes.length === 0) c.problem('deploy.backup.volumes', 'must list at least one volume');
272
+ return { image: block.image, keepDays: block.keepDays ?? 14, dir: block.dir ?? null, volumes };
273
+ }
274
+
275
+ function ingress(c, block, probe) {
276
+ if (block === undefined) return null;
277
+ const result = normalizeIngress(block, { probe });
278
+ for (const item of result.problems) c.problem(item.at, item.message);
279
+ return result.ingress;
280
+ }
281
+
282
+ /**
283
+ * Validates and normalises one `deploy` block.
284
+ * probe.exists(rel) does a file exist below .starcistacks/<env>/ ?
285
+ * probe.services the service names of the committed stack source (empty set = unknown, service references are not checked)
286
+ * probe.members the sealed member slugs of .starcistacks/<env>/secrets (empty set = unknown)
287
+ * Returns { config, problems } ; config is null when a problem was found.
288
+ */
289
+ export function normalizeDeploy(block, { project, probe }) {
290
+ const c = collector();
291
+ if (!closedFields(c, 'deploy', block, FIELDS.deploy)) return { config: null, problems: c.problems };
292
+ const services = probe.services ?? new Set();
293
+ const members = probe.members ?? new Set();
294
+ if (block.stack !== undefined) nameField(c, 'deploy.stack', block.stack);
295
+ const config = {
296
+ stack: block.stack ?? project,
297
+ remote: remote(c, block.remote),
298
+ images: images(c, block.images, services, members),
299
+ volumes: block.volumes === undefined ? [] : stringList(c, 'deploy.volumes', block.volumes, VOLUME),
300
+ secrets: secrets(c, block.secrets, services, members),
301
+ environment: environmentBlock(c, block.environment, services, probe),
302
+ configs: configs(c, block.configs, probe),
303
+ passEnv: block.passEnv === undefined ? [] : stringList(c, 'deploy.passEnv', block.passEnv, KEY),
304
+ verify: verify(c, block.verify, services),
305
+ jobs: jobs(c, block.jobs, services),
306
+ tasks: tasks(c, block.tasks, services),
307
+ backup: backup(c, block.backup),
308
+ ingress: ingress(c, block.ingress, probe),
309
+ registry: registry(c, block.registry),
310
+ };
311
+ if (block.stack === undefined && !NAME.test(String(project))) c.problem('deploy.stack', `the stack name ${project} must match ${NAME}`);
312
+ return { config: c.problems.length === 0 ? config : null, problems: c.problems };
313
+ }