gina 0.6.8 → 0.6.9

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 (579) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +25 -28
  3. package/ROADMAP.md +2 -1
  4. package/framework/v0.6.9/VERSION +1 -0
  5. package/framework/{v0.6.8 → v0.6.9}/core/config.js +102 -1
  6. package/framework/{v0.6.8 → v0.6.9}/core/controller/controller.js +32 -4
  7. package/framework/{v0.6.8 → v0.6.9}/core/gna.js +107 -0
  8. package/framework/{v0.6.8 → v0.6.9}/core/server.express.js +40 -1
  9. package/framework/{v0.6.8 → v0.6.9}/core/server.isaac.js +110 -3
  10. package/framework/{v0.6.8 → v0.6.9}/core/server.js +16 -2
  11. package/framework/{v0.6.8 → v0.6.9}/helpers/path.js +108 -26
  12. package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/build.js +37 -0
  13. package/framework/{v0.6.8 → v0.6.9}/lib/cmd/helper.js +148 -49
  14. package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/add.js +75 -3
  15. package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/build.js +24 -0
  16. package/framework/{v0.6.8 → v0.6.9}/lib/index.js +8 -0
  17. package/framework/v0.6.9/lib/push/package.json +19 -0
  18. package/framework/v0.6.9/lib/push/src/main.js +144 -0
  19. package/framework/{v0.6.8 → v0.6.9}/lib/secrets/src/backends/env.js +15 -4
  20. package/framework/{v0.6.8 → v0.6.9}/lib/secrets/src/main.js +48 -4
  21. package/framework/{v0.6.8 → v0.6.9}/package.json +1 -1
  22. package/gna.js +4 -4
  23. package/llms.txt +9 -6
  24. package/package.json +3 -3
  25. package/schema/manifest.json +6 -0
  26. package/schema/settings.json +3 -2
  27. package/types/index.d.ts +39 -0
  28. package/framework/v0.6.8/VERSION +0 -1
  29. /package/framework/{v0.6.8 → v0.6.9}/AUTHORS +0 -0
  30. /package/framework/{v0.6.8 → v0.6.9}/LICENSE +0 -0
  31. /package/framework/{v0.6.8 → v0.6.9}/core/asset/html/nolayout.html +0 -0
  32. /package/framework/{v0.6.8 → v0.6.9}/core/asset/html/static.html +0 -0
  33. /package/framework/{v0.6.8 → v0.6.9}/core/asset/img/android-chrome-192x192.png +0 -0
  34. /package/framework/{v0.6.8 → v0.6.9}/core/asset/img/android-chrome-512x512.png +0 -0
  35. /package/framework/{v0.6.8 → v0.6.9}/core/asset/img/apple-touch-icon.png +0 -0
  36. /package/framework/{v0.6.8 → v0.6.9}/core/asset/img/favicon-16x16.png +0 -0
  37. /package/framework/{v0.6.8 → v0.6.9}/core/asset/img/favicon-32x32.png +0 -0
  38. /package/framework/{v0.6.8 → v0.6.9}/core/asset/img/favicon.ico +0 -0
  39. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/README.md +0 -0
  40. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/beemaster/beemaster.css +0 -0
  41. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/beemaster/beemaster.js +0 -0
  42. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/beemaster/index.html +0 -0
  43. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/css/gina.min.css +0 -0
  44. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/css/gina.min.css.br +0 -0
  45. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/css/gina.min.css.gz +0 -0
  46. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/html/statusbar.html +0 -0
  47. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/html/statusbar.html.br +0 -0
  48. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/html/statusbar.html.gz +0 -0
  49. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/inspector/have_heart_one-webfont.woff2 +0 -0
  50. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/inspector/index.html +0 -0
  51. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/inspector/inspector.css +0 -0
  52. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/inspector/inspector.js +0 -0
  53. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/inspector/logo.svg +0 -0
  54. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/js/gina.js +0 -0
  55. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/js/gina.min.js +0 -0
  56. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/js/gina.min.js.br +0 -0
  57. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/js/gina.min.js.gz +0 -0
  58. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/js/gina.onload.min.js +0 -0
  59. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/js/gina.onload.min.js.br +0 -0
  60. /package/framework/{v0.6.8 → v0.6.9}/core/asset/plugin/dist/vendor/gina/js/gina.onload.min.js.gz +0 -0
  61. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/ai/index.js +0 -0
  62. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/ai/lib/connector.js +0 -0
  63. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/couchbase/index.js +0 -0
  64. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/couchbase/lib/connector.js +0 -0
  65. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/couchbase/lib/connector.v3.js +0 -0
  66. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/couchbase/lib/connector.v4.js +0 -0
  67. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/couchbase/lib/n1ql.js +0 -0
  68. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/couchbase/lib/session-store.js +0 -0
  69. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/couchbase/lib/session-store.v3.js +0 -0
  70. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/couchbase/lib/session-store.v4.js +0 -0
  71. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/couchbase/lib/storage-store.js +0 -0
  72. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/duckdb/index.js +0 -0
  73. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/duckdb/lib/connector.js +0 -0
  74. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/mongodb/index.js +0 -0
  75. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/mongodb/lib/connector.js +0 -0
  76. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/mongodb/lib/job-store.js +0 -0
  77. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/mongodb/lib/pipeline-loader.js +0 -0
  78. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/mongodb/lib/session-store.js +0 -0
  79. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/mysql/index.js +0 -0
  80. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/mysql/lib/connector.js +0 -0
  81. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/param-redact.js +0 -0
  82. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/postgresql/index.js +0 -0
  83. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/postgresql/lib/connector.js +0 -0
  84. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/redis/index.js +0 -0
  85. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/redis/lib/connector.js +0 -0
  86. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/redis/lib/job-store.js +0 -0
  87. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/redis/lib/render-cache-store.js +0 -0
  88. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/redis/lib/session-store.js +0 -0
  89. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/scylladb/index.js +0 -0
  90. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/scylladb/lib/connector.js +0 -0
  91. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/scylladb/lib/session-store.js +0 -0
  92. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/sql-parser.js +0 -0
  93. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/sqlite/index.js +0 -0
  94. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/sqlite/lib/connector.js +0 -0
  95. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/sqlite/lib/job-store.js +0 -0
  96. /package/framework/{v0.6.8 → v0.6.9}/core/connectors/sqlite/lib/session-store.js +0 -0
  97. /package/framework/{v0.6.8 → v0.6.9}/core/content.encoding +0 -0
  98. /package/framework/{v0.6.8 → v0.6.9}/core/controller/controller.framework.js +0 -0
  99. /package/framework/{v0.6.8 → v0.6.9}/core/controller/controller.render-json.js +0 -0
  100. /package/framework/{v0.6.8 → v0.6.9}/core/controller/controller.render-nunjucks-async.js +0 -0
  101. /package/framework/{v0.6.8 → v0.6.9}/core/controller/controller.render-nunjucks.js +0 -0
  102. /package/framework/{v0.6.8 → v0.6.9}/core/controller/controller.render-stream.js +0 -0
  103. /package/framework/{v0.6.8 → v0.6.9}/core/controller/controller.render-swig-async.js +0 -0
  104. /package/framework/{v0.6.8 → v0.6.9}/core/controller/controller.render-swig.js +0 -0
  105. /package/framework/{v0.6.8 → v0.6.9}/core/controller/controller.render-v1.js +0 -0
  106. /package/framework/{v0.6.8 → v0.6.9}/core/controller/index.js +0 -0
  107. /package/framework/{v0.6.8 → v0.6.9}/core/controller/inspector-window-emit.js +0 -0
  108. /package/framework/{v0.6.8 → v0.6.9}/core/controller/release-banner.js +0 -0
  109. /package/framework/{v0.6.8 → v0.6.9}/core/dev/index.js +0 -0
  110. /package/framework/{v0.6.8 → v0.6.9}/core/dev/lib/class.js +0 -0
  111. /package/framework/{v0.6.8 → v0.6.9}/core/dev/lib/factory.js +0 -0
  112. /package/framework/{v0.6.8 → v0.6.9}/core/dev/lib/tools.js +0 -0
  113. /package/framework/{v0.6.8 → v0.6.9}/core/locales/README.md +0 -0
  114. /package/framework/{v0.6.8 → v0.6.9}/core/locales/currency.json +0 -0
  115. /package/framework/{v0.6.8 → v0.6.9}/core/locales/dist/language/en.json +0 -0
  116. /package/framework/{v0.6.8 → v0.6.9}/core/locales/dist/language/fr.json +0 -0
  117. /package/framework/{v0.6.8 → v0.6.9}/core/locales/dist/region/en.json +0 -0
  118. /package/framework/{v0.6.8 → v0.6.9}/core/locales/dist/region/fr.json +0 -0
  119. /package/framework/{v0.6.8 → v0.6.9}/core/locales/index.js +0 -0
  120. /package/framework/{v0.6.8 → v0.6.9}/core/mime.types +0 -0
  121. /package/framework/{v0.6.8 → v0.6.9}/core/model/entity.js +0 -0
  122. /package/framework/{v0.6.8 → v0.6.9}/core/model/index.js +0 -0
  123. /package/framework/{v0.6.8 → v0.6.9}/core/model/template/entityFactory.js +0 -0
  124. /package/framework/{v0.6.8 → v0.6.9}/core/model/template/index.js +0 -0
  125. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/README.md +0 -0
  126. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/index.js +0 -0
  127. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/csrf/README.md +0 -0
  128. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/csrf/package.json +0 -0
  129. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/csrf/src/main.js +0 -0
  130. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/README.md +0 -0
  131. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/coep/README.md +0 -0
  132. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/coep/package.json +0 -0
  133. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/coep/src/main.js +0 -0
  134. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/coop/README.md +0 -0
  135. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/coop/package.json +0 -0
  136. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/coop/src/main.js +0 -0
  137. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/corp/README.md +0 -0
  138. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/corp/package.json +0 -0
  139. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/corp/src/main.js +0 -0
  140. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/csp/README.md +0 -0
  141. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/csp/package.json +0 -0
  142. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/csp/src/main.js +0 -0
  143. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/hide-powered-by/README.md +0 -0
  144. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/hide-powered-by/package.json +0 -0
  145. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/hide-powered-by/src/main.js +0 -0
  146. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/hsts/README.md +0 -0
  147. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/hsts/package.json +0 -0
  148. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/hsts/src/main.js +0 -0
  149. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/origin-agent-cluster/README.md +0 -0
  150. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/origin-agent-cluster/package.json +0 -0
  151. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/origin-agent-cluster/src/main.js +0 -0
  152. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/package.json +0 -0
  153. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/referrer-policy/README.md +0 -0
  154. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/referrer-policy/package.json +0 -0
  155. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/referrer-policy/src/main.js +0 -0
  156. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/src/main.js +0 -0
  157. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-content-type-options/README.md +0 -0
  158. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-content-type-options/package.json +0 -0
  159. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-content-type-options/src/main.js +0 -0
  160. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-dns-prefetch-control/README.md +0 -0
  161. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-dns-prefetch-control/package.json +0 -0
  162. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-dns-prefetch-control/src/main.js +0 -0
  163. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-download-options/README.md +0 -0
  164. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-download-options/package.json +0 -0
  165. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-download-options/src/main.js +0 -0
  166. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-frame-options/README.md +0 -0
  167. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-frame-options/package.json +0 -0
  168. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-frame-options/src/main.js +0 -0
  169. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-permitted-cross-domain-policies/README.md +0 -0
  170. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-permitted-cross-domain-policies/package.json +0 -0
  171. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-permitted-cross-domain-policies/src/main.js +0 -0
  172. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-xss-protection/README.md +0 -0
  173. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-xss-protection/package.json +0 -0
  174. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/security-headers/x-xss-protection/src/main.js +0 -0
  175. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/session/README.md +0 -0
  176. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/session/package.json +0 -0
  177. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/session/src/main.js +0 -0
  178. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/storage/README.md +0 -0
  179. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/storage/build.json +0 -0
  180. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/storage/package.json +0 -0
  181. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/storage/src/main.js +0 -0
  182. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/validator/README.md +0 -0
  183. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/validator/build.json +0 -0
  184. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/validator/package.json +0 -0
  185. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/validator/src/form-validator.js +0 -0
  186. /package/framework/{v0.6.8 → v0.6.9}/core/plugins/lib/validator/src/main.js +0 -0
  187. /package/framework/{v0.6.8 → v0.6.9}/core/router.js +0 -0
  188. /package/framework/{v0.6.8 → v0.6.9}/core/status.codes +0 -0
  189. /package/framework/{v0.6.8 → v0.6.9}/core/template/_gitignore +0 -0
  190. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle/config/app.json +0 -0
  191. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle/config/connectors.json +0 -0
  192. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle/config/routing.json +0 -0
  193. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle/config/settings.json +0 -0
  194. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle/config/settings.server.json +0 -0
  195. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle/config/templates.json +0 -0
  196. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle/config/watchers.json +0 -0
  197. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle/controllers/controller.content.js +0 -0
  198. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle/controllers/controller.js +0 -0
  199. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle/controllers/setup.js +0 -0
  200. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle/index.js +0 -0
  201. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle/locales/en.json +0 -0
  202. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_namespace/controllers/controller.js +0 -0
  203. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_public/css/default.css +0 -0
  204. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_public/css/home.css +0 -0
  205. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_public/css/vendor/readme.md +0 -0
  206. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_public/favicon.ico +0 -0
  207. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_public/js/components/x-checklist.js +0 -0
  208. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_public/js/vendor/readme.md +0 -0
  209. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_public/manifest.webmanifest +0 -0
  210. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_public/readme.md +0 -0
  211. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_public/sw.js +0 -0
  212. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_templates/handlers/main.js +0 -0
  213. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_templates/html/content/homepage.html +0 -0
  214. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_templates/html/includes/error-msg-noscript.html +0 -0
  215. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_templates/html/includes/error-msg-outdated-browser.html +0 -0
  216. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_templates/html/includes/x-checklist.html +0 -0
  217. /package/framework/{v0.6.8 → v0.6.9}/core/template/boilerplate/bundle_templates/html/layouts/main.html +0 -0
  218. /package/framework/{v0.6.8 → v0.6.9}/core/template/command/gina.bat.tpl +0 -0
  219. /package/framework/{v0.6.8 → v0.6.9}/core/template/command/gina.tpl +0 -0
  220. /package/framework/{v0.6.8 → v0.6.9}/core/template/conf/env.json +0 -0
  221. /package/framework/{v0.6.8 → v0.6.9}/core/template/conf/manifest.json +0 -0
  222. /package/framework/{v0.6.8 → v0.6.9}/core/template/conf/package.json +0 -0
  223. /package/framework/{v0.6.8 → v0.6.9}/core/template/conf/settings.json +0 -0
  224. /package/framework/{v0.6.8 → v0.6.9}/core/template/conf/statics.json +0 -0
  225. /package/framework/{v0.6.8 → v0.6.9}/core/template/conf/templates.json +0 -0
  226. /package/framework/{v0.6.8 → v0.6.9}/core/template/error/client/json/401.json +0 -0
  227. /package/framework/{v0.6.8 → v0.6.9}/core/template/error/client/json/403.json +0 -0
  228. /package/framework/{v0.6.8 → v0.6.9}/core/template/error/client/json/404.json +0 -0
  229. /package/framework/{v0.6.8 → v0.6.9}/core/template/error/server/html/50x.html +0 -0
  230. /package/framework/{v0.6.8 → v0.6.9}/core/template/error/server/json/500.json +0 -0
  231. /package/framework/{v0.6.8 → v0.6.9}/core/template/error/server/json/503.json +0 -0
  232. /package/framework/{v0.6.8 → v0.6.9}/core/template/extensions/logger/config.json +0 -0
  233. /package/framework/{v0.6.8 → v0.6.9}/helpers/console.js +0 -0
  234. /package/framework/{v0.6.8 → v0.6.9}/helpers/context.js +0 -0
  235. /package/framework/{v0.6.8 → v0.6.9}/helpers/data/LICENSE +0 -0
  236. /package/framework/{v0.6.8 → v0.6.9}/helpers/data/README.md +0 -0
  237. /package/framework/{v0.6.8 → v0.6.9}/helpers/data/package.json +0 -0
  238. /package/framework/{v0.6.8 → v0.6.9}/helpers/data/src/main.js +0 -0
  239. /package/framework/{v0.6.8 → v0.6.9}/helpers/dateFormat.js +0 -0
  240. /package/framework/{v0.6.8 → v0.6.9}/helpers/index.js +0 -0
  241. /package/framework/{v0.6.8 → v0.6.9}/helpers/json/LICENSE +0 -0
  242. /package/framework/{v0.6.8 → v0.6.9}/helpers/json/README.md +0 -0
  243. /package/framework/{v0.6.8 → v0.6.9}/helpers/json/package.json +0 -0
  244. /package/framework/{v0.6.8 → v0.6.9}/helpers/json/src/main.js +0 -0
  245. /package/framework/{v0.6.8 → v0.6.9}/helpers/plugins/README.md +0 -0
  246. /package/framework/{v0.6.8 → v0.6.9}/helpers/plugins/package.json +0 -0
  247. /package/framework/{v0.6.8 → v0.6.9}/helpers/plugins/src/api-error.js +0 -0
  248. /package/framework/{v0.6.8 → v0.6.9}/helpers/plugins/src/main.js +0 -0
  249. /package/framework/{v0.6.8 → v0.6.9}/helpers/prototypes.js +0 -0
  250. /package/framework/{v0.6.8 → v0.6.9}/helpers/task.js +0 -0
  251. /package/framework/{v0.6.8 → v0.6.9}/helpers/text.js +0 -0
  252. /package/framework/{v0.6.8 → v0.6.9}/lib/admin/package.json +0 -0
  253. /package/framework/{v0.6.8 → v0.6.9}/lib/admin/src/main.js +0 -0
  254. /package/framework/{v0.6.8 → v0.6.9}/lib/archiver/README.md +0 -0
  255. /package/framework/{v0.6.8 → v0.6.9}/lib/archiver/build.json +0 -0
  256. /package/framework/{v0.6.8 → v0.6.9}/lib/archiver/package.json +0 -0
  257. /package/framework/{v0.6.8 → v0.6.9}/lib/archiver/src/dep/jszip.min.js +0 -0
  258. /package/framework/{v0.6.8 → v0.6.9}/lib/archiver/src/main.js +0 -0
  259. /package/framework/{v0.6.8 → v0.6.9}/lib/async/package.json +0 -0
  260. /package/framework/{v0.6.8 → v0.6.9}/lib/async/src/main.js +0 -0
  261. /package/framework/{v0.6.8 → v0.6.9}/lib/audit/package.json +0 -0
  262. /package/framework/{v0.6.8 → v0.6.9}/lib/audit/src/main.js +0 -0
  263. /package/framework/{v0.6.8 → v0.6.9}/lib/audit-store.js +0 -0
  264. /package/framework/{v0.6.8 → v0.6.9}/lib/authn/package.json +0 -0
  265. /package/framework/{v0.6.8 → v0.6.9}/lib/authn/src/lockout.js +0 -0
  266. /package/framework/{v0.6.8 → v0.6.9}/lib/authn/src/main.js +0 -0
  267. /package/framework/{v0.6.8 → v0.6.9}/lib/authn/src/totp.js +0 -0
  268. /package/framework/{v0.6.8 → v0.6.9}/lib/authz-gate/package.json +0 -0
  269. /package/framework/{v0.6.8 → v0.6.9}/lib/authz-gate/src/main.js +0 -0
  270. /package/framework/{v0.6.8 → v0.6.9}/lib/cache/README.md +0 -0
  271. /package/framework/{v0.6.8 → v0.6.9}/lib/cache/build.json +0 -0
  272. /package/framework/{v0.6.8 → v0.6.9}/lib/cache/package.json +0 -0
  273. /package/framework/{v0.6.8 → v0.6.9}/lib/cache/src/main.js +0 -0
  274. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/aliases.json +0 -0
  275. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/audit/arguments.json +0 -0
  276. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/audit/help.txt +0 -0
  277. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/audit/verify.js +0 -0
  278. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/add.js +0 -0
  279. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/arguments.json +0 -0
  280. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/copy.js +0 -0
  281. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/cp.js +0 -0
  282. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/help.js +0 -0
  283. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/help.txt +0 -0
  284. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/inc/name-rewrite.js +0 -0
  285. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/list.js +0 -0
  286. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/man.js +0 -0
  287. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/mcp-start.js +0 -0
  288. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/mcp.js +0 -0
  289. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/oas.js +0 -0
  290. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/openapi.js +0 -0
  291. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/remove.js +0 -0
  292. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/rename.js +0 -0
  293. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/restart.js +0 -0
  294. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/rm.js +0 -0
  295. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/start.js +0 -0
  296. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/status.js +0 -0
  297. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/stop.js +0 -0
  298. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/bundle/types.js +0 -0
  299. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/cache/arguments.json +0 -0
  300. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/cache/clear.js +0 -0
  301. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/cache/help.txt +0 -0
  302. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/cache/stats.js +0 -0
  303. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/connector/add.js +0 -0
  304. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/connector/arguments.json +0 -0
  305. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/connector/help.js +0 -0
  306. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/connector/help.txt +0 -0
  307. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/connector/infer.js +0 -0
  308. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/connector/list.js +0 -0
  309. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/connector/migrate.js +0 -0
  310. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/connector/models.js +0 -0
  311. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/connector/remove.js +0 -0
  312. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/connector/rm.js +0 -0
  313. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/connector/test.js +0 -0
  314. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/container/arguments.json +0 -0
  315. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/container/help.js +0 -0
  316. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/container/help.txt +0 -0
  317. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/container/man.js +0 -0
  318. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/container/ps.js +0 -0
  319. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/container/stop.js +0 -0
  320. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/controller/add.js +0 -0
  321. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/controller/arguments.json +0 -0
  322. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/controller/help.txt +0 -0
  323. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/controller/inc/args.js +0 -0
  324. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/controller/inc/namespace.js +0 -0
  325. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/controller/inc/reference-rewrite.js +0 -0
  326. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/controller/inc/reference-scan.js +0 -0
  327. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/controller/inc/scaffold.js +0 -0
  328. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/controller/remove.js +0 -0
  329. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/controller/rename.js +0 -0
  330. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/controller/rm.js +0 -0
  331. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/env/add.js +0 -0
  332. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/env/get.js +0 -0
  333. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/env/help.js +0 -0
  334. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/env/help.txt +0 -0
  335. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/env/link-dev.js +0 -0
  336. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/env/list.js +0 -0
  337. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/env/remove.js +0 -0
  338. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/env/rm.js +0 -0
  339. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/env/set.js +0 -0
  340. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/env/unset.js +0 -0
  341. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/env/use.js +0 -0
  342. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/add.js +0 -0
  343. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/arguments.json +0 -0
  344. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/build.js +0 -0
  345. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/dot.js +0 -0
  346. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/get.js +0 -0
  347. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/help.js +0 -0
  348. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/help.txt +0 -0
  349. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/init.js +0 -0
  350. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/link-node-modules.js +0 -0
  351. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/link.js +0 -0
  352. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/list.js +0 -0
  353. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/man.js +0 -0
  354. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/msg.json +0 -0
  355. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/open.js +0 -0
  356. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/remove.js +0 -0
  357. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/reset.js +0 -0
  358. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/restart.js +0 -0
  359. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/set.js +0 -0
  360. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/start.js +0 -0
  361. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/status.js +0 -0
  362. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/stop.js +0 -0
  363. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/tail.js +0 -0
  364. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/update.js +0 -0
  365. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/framework/version.js +0 -0
  366. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/gina-dev.1.md +0 -0
  367. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/gina-framework.1.md +0 -0
  368. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/gina.1.md +0 -0
  369. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/i18n/add.js +0 -0
  370. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/i18n/arguments.json +0 -0
  371. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/i18n/export.js +0 -0
  372. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/i18n/help.js +0 -0
  373. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/i18n/help.txt +0 -0
  374. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/i18n/import.js +0 -0
  375. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/i18n/scan.js +0 -0
  376. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/image/_host.js +0 -0
  377. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/image/arguments.json +0 -0
  378. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/image/build.js +0 -0
  379. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/image/help.js +0 -0
  380. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/image/help.txt +0 -0
  381. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/image/list.js +0 -0
  382. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/image/man.js +0 -0
  383. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/image/rm.js +0 -0
  384. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/image/run.js +0 -0
  385. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/index.js +0 -0
  386. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/inspector/help.js +0 -0
  387. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/inspector/help.txt +0 -0
  388. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/inspector/open.js +0 -0
  389. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/man-render.js +0 -0
  390. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/minion/arguments.json +0 -0
  391. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/minion/help.js +0 -0
  392. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/minion/help.txt +0 -0
  393. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/minion/kill.js +0 -0
  394. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/minion/list.js +0 -0
  395. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/msg.json +0 -0
  396. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/port/help.js +0 -0
  397. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/port/help.txt +0 -0
  398. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/port/inc/scan.js +0 -0
  399. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/port/list.js +0 -0
  400. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/port/reset.js +0 -0
  401. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/port/set.js +0 -0
  402. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/arguments.json +0 -0
  403. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/backup.js +0 -0
  404. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/help.js +0 -0
  405. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/help.txt +0 -0
  406. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/import.js +0 -0
  407. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/list.js +0 -0
  408. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/man.js +0 -0
  409. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/move.js +0 -0
  410. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/remove.js +0 -0
  411. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/rename.js +0 -0
  412. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/restart.js +0 -0
  413. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/restore.js +0 -0
  414. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/rm.js +0 -0
  415. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/start.js +0 -0
  416. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/status.js +0 -0
  417. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/project/stop.js +0 -0
  418. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/protocol/arguments.json +0 -0
  419. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/protocol/help.js +0 -0
  420. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/protocol/help.txt +0 -0
  421. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/protocol/list.js +0 -0
  422. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/protocol/remove.js +0 -0
  423. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/protocol/set.js +0 -0
  424. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/scope/add.js +0 -0
  425. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/scope/help.js +0 -0
  426. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/scope/help.txt +0 -0
  427. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/scope/link-local.js +0 -0
  428. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/scope/link-production.js +0 -0
  429. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/scope/list.js +0 -0
  430. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/scope/remove.js +0 -0
  431. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/scope/rm.js +0 -0
  432. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/scope/use.js +0 -0
  433. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/secrets/arguments.json +0 -0
  434. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/secrets/check.js +0 -0
  435. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/secrets/help.js +0 -0
  436. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/secrets/help.txt +0 -0
  437. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/secrets/scan.js +0 -0
  438. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/service/help.js +0 -0
  439. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/service/help.txt +0 -0
  440. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/service/list.js +0 -0
  441. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/service/man.js +0 -0
  442. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/service/start.js +0 -0
  443. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/storage/arguments.json +0 -0
  444. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/storage/gc.js +0 -0
  445. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/storage/help.txt +0 -0
  446. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/storage/stats.js +0 -0
  447. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/storage/verify.js +0 -0
  448. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd/view/add.js +0 -0
  449. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd-status-format/package.json +0 -0
  450. /package/framework/{v0.6.8 → v0.6.9}/lib/cmd-status-format/src/main.js +0 -0
  451. /package/framework/{v0.6.8 → v0.6.9}/lib/collection/README.md +0 -0
  452. /package/framework/{v0.6.8 → v0.6.9}/lib/collection/build.json +0 -0
  453. /package/framework/{v0.6.8 → v0.6.9}/lib/collection/package.json +0 -0
  454. /package/framework/{v0.6.8 → v0.6.9}/lib/collection/src/main.js +0 -0
  455. /package/framework/{v0.6.8 → v0.6.9}/lib/config.js +0 -0
  456. /package/framework/{v0.6.8 → v0.6.9}/lib/connector-config/package.json +0 -0
  457. /package/framework/{v0.6.8 → v0.6.9}/lib/connector-config/src/main.js +0 -0
  458. /package/framework/{v0.6.8 → v0.6.9}/lib/connector-error/package.json +0 -0
  459. /package/framework/{v0.6.8 → v0.6.9}/lib/connector-error/src/main.js +0 -0
  460. /package/framework/{v0.6.8 → v0.6.9}/lib/connector-registry/package.json +0 -0
  461. /package/framework/{v0.6.8 → v0.6.9}/lib/connector-registry/src/main.js +0 -0
  462. /package/framework/{v0.6.8 → v0.6.9}/lib/cron/README.md +0 -0
  463. /package/framework/{v0.6.8 → v0.6.9}/lib/cron/package.json +0 -0
  464. /package/framework/{v0.6.8 → v0.6.9}/lib/cron/src/main.js +0 -0
  465. /package/framework/{v0.6.8 → v0.6.9}/lib/domain/LICENSE +0 -0
  466. /package/framework/{v0.6.8 → v0.6.9}/lib/domain/README.md +0 -0
  467. /package/framework/{v0.6.8 → v0.6.9}/lib/domain/package.json +0 -0
  468. /package/framework/{v0.6.8 → v0.6.9}/lib/domain/src/main.js +0 -0
  469. /package/framework/{v0.6.8 → v0.6.9}/lib/dto/package.json +0 -0
  470. /package/framework/{v0.6.8 → v0.6.9}/lib/dto/src/main.js +0 -0
  471. /package/framework/{v0.6.8 → v0.6.9}/lib/dto-pipe/package.json +0 -0
  472. /package/framework/{v0.6.8 → v0.6.9}/lib/dto-pipe/src/main.js +0 -0
  473. /package/framework/{v0.6.8 → v0.6.9}/lib/dto-types/package.json +0 -0
  474. /package/framework/{v0.6.8 → v0.6.9}/lib/dto-types/src/main.js +0 -0
  475. /package/framework/{v0.6.8 → v0.6.9}/lib/generator/index.js +0 -0
  476. /package/framework/{v0.6.8 → v0.6.9}/lib/i18n/package.json +0 -0
  477. /package/framework/{v0.6.8 → v0.6.9}/lib/i18n/src/main.js +0 -0
  478. /package/framework/{v0.6.8 → v0.6.9}/lib/image-build/package.json +0 -0
  479. /package/framework/{v0.6.8 → v0.6.9}/lib/image-build/src/main.js +0 -0
  480. /package/framework/{v0.6.8 → v0.6.9}/lib/inherits/LICENSE +0 -0
  481. /package/framework/{v0.6.8 → v0.6.9}/lib/inherits/README.md +0 -0
  482. /package/framework/{v0.6.8 → v0.6.9}/lib/inherits/package.json +0 -0
  483. /package/framework/{v0.6.8 → v0.6.9}/lib/inherits/src/main.js +0 -0
  484. /package/framework/{v0.6.8 → v0.6.9}/lib/inspector-events/package.json +0 -0
  485. /package/framework/{v0.6.8 → v0.6.9}/lib/inspector-events/src/main.js +0 -0
  486. /package/framework/{v0.6.8 → v0.6.9}/lib/inspector-redact/package.json +0 -0
  487. /package/framework/{v0.6.8 → v0.6.9}/lib/inspector-redact/src/main.js +0 -0
  488. /package/framework/{v0.6.8 → v0.6.9}/lib/instrument/package.json +0 -0
  489. /package/framework/{v0.6.8 → v0.6.9}/lib/instrument/src/main.js +0 -0
  490. /package/framework/{v0.6.8 → v0.6.9}/lib/job/package.json +0 -0
  491. /package/framework/{v0.6.8 → v0.6.9}/lib/job/src/main.js +0 -0
  492. /package/framework/{v0.6.8 → v0.6.9}/lib/job-store.js +0 -0
  493. /package/framework/{v0.6.8 → v0.6.9}/lib/json-config-header/package.json +0 -0
  494. /package/framework/{v0.6.8 → v0.6.9}/lib/json-config-header/src/main.js +0 -0
  495. /package/framework/{v0.6.8 → v0.6.9}/lib/loading-state/package.json +0 -0
  496. /package/framework/{v0.6.8 → v0.6.9}/lib/loading-state/src/main.js +0 -0
  497. /package/framework/{v0.6.8 → v0.6.9}/lib/logger/README.md +0 -0
  498. /package/framework/{v0.6.8 → v0.6.9}/lib/logger/package.json +0 -0
  499. /package/framework/{v0.6.8 → v0.6.9}/lib/logger/src/containers/default/index.js +0 -0
  500. /package/framework/{v0.6.8 → v0.6.9}/lib/logger/src/containers/file/index.js +0 -0
  501. /package/framework/{v0.6.8 → v0.6.9}/lib/logger/src/containers/file/lib/logrotator/README.md +0 -0
  502. /package/framework/{v0.6.8 → v0.6.9}/lib/logger/src/containers/file/lib/logrotator/index.js +0 -0
  503. /package/framework/{v0.6.8 → v0.6.9}/lib/logger/src/containers/mq/index.js +0 -0
  504. /package/framework/{v0.6.8 → v0.6.9}/lib/logger/src/containers/mq/listener.js +0 -0
  505. /package/framework/{v0.6.8 → v0.6.9}/lib/logger/src/containers/mq/speaker.js +0 -0
  506. /package/framework/{v0.6.8 → v0.6.9}/lib/logger/src/helper.js +0 -0
  507. /package/framework/{v0.6.8 → v0.6.9}/lib/logger/src/main.js +0 -0
  508. /package/framework/{v0.6.8 → v0.6.9}/lib/math/index.js +0 -0
  509. /package/framework/{v0.6.8 → v0.6.9}/lib/mcp-dispatch/package.json +0 -0
  510. /package/framework/{v0.6.8 → v0.6.9}/lib/mcp-dispatch/src/main.js +0 -0
  511. /package/framework/{v0.6.8 → v0.6.9}/lib/mcp-http/package.json +0 -0
  512. /package/framework/{v0.6.8 → v0.6.9}/lib/mcp-http/src/main.js +0 -0
  513. /package/framework/{v0.6.8 → v0.6.9}/lib/mcp-server/package.json +0 -0
  514. /package/framework/{v0.6.8 → v0.6.9}/lib/mcp-server/src/main.js +0 -0
  515. /package/framework/{v0.6.8 → v0.6.9}/lib/merge/README.md +0 -0
  516. /package/framework/{v0.6.8 → v0.6.9}/lib/merge/package.json +0 -0
  517. /package/framework/{v0.6.8 → v0.6.9}/lib/merge/src/main.js +0 -0
  518. /package/framework/{v0.6.8 → v0.6.9}/lib/metrics/package.json +0 -0
  519. /package/framework/{v0.6.8 → v0.6.9}/lib/metrics/src/main.js +0 -0
  520. /package/framework/{v0.6.8 → v0.6.9}/lib/model.js +0 -0
  521. /package/framework/{v0.6.8 → v0.6.9}/lib/net-locality/package.json +0 -0
  522. /package/framework/{v0.6.8 → v0.6.9}/lib/net-locality/src/main.js +0 -0
  523. /package/framework/{v0.6.8 → v0.6.9}/lib/nunjucks-filters/README.md +0 -0
  524. /package/framework/{v0.6.8 → v0.6.9}/lib/nunjucks-filters/package.json +0 -0
  525. /package/framework/{v0.6.8 → v0.6.9}/lib/nunjucks-filters/src/main.js +0 -0
  526. /package/framework/{v0.6.8 → v0.6.9}/lib/nunjucks-resolver/package.json +0 -0
  527. /package/framework/{v0.6.8 → v0.6.9}/lib/nunjucks-resolver/src/main.js +0 -0
  528. /package/framework/{v0.6.8 → v0.6.9}/lib/proc.js +0 -0
  529. /package/framework/{v0.6.8 → v0.6.9}/lib/release-watch/package.json +0 -0
  530. /package/framework/{v0.6.8 → v0.6.9}/lib/release-watch/src/main.js +0 -0
  531. /package/framework/{v0.6.8 → v0.6.9}/lib/render-cache/package.json +0 -0
  532. /package/framework/{v0.6.8 → v0.6.9}/lib/render-cache/src/main.js +0 -0
  533. /package/framework/{v0.6.8 → v0.6.9}/lib/render-cache-store.js +0 -0
  534. /package/framework/{v0.6.8 → v0.6.9}/lib/routing/README.md +0 -0
  535. /package/framework/{v0.6.8 → v0.6.9}/lib/routing/build.json +0 -0
  536. /package/framework/{v0.6.8 → v0.6.9}/lib/routing/package.json +0 -0
  537. /package/framework/{v0.6.8 → v0.6.9}/lib/routing/src/main.js +0 -0
  538. /package/framework/{v0.6.8 → v0.6.9}/lib/routing/src/radix.js +0 -0
  539. /package/framework/{v0.6.8 → v0.6.9}/lib/routing-introspect/package.json +0 -0
  540. /package/framework/{v0.6.8 → v0.6.9}/lib/routing-introspect/src/main.js +0 -0
  541. /package/framework/{v0.6.8 → v0.6.9}/lib/secrets/package.json +0 -0
  542. /package/framework/{v0.6.8 → v0.6.9}/lib/secrets/src/backends/file.js +0 -0
  543. /package/framework/{v0.6.8 → v0.6.9}/lib/secrets/src/env-file.js +0 -0
  544. /package/framework/{v0.6.8 → v0.6.9}/lib/session-store.js +0 -0
  545. /package/framework/{v0.6.8 → v0.6.9}/lib/shell.js +0 -0
  546. /package/framework/{v0.6.8 → v0.6.9}/lib/sqlite-driver.js +0 -0
  547. /package/framework/{v0.6.8 → v0.6.9}/lib/state.js +0 -0
  548. /package/framework/{v0.6.8 → v0.6.9}/lib/storage/package.json +0 -0
  549. /package/framework/{v0.6.8 → v0.6.9}/lib/storage/src/local-cas.js +0 -0
  550. /package/framework/{v0.6.8 → v0.6.9}/lib/storage/src/local-stream.js +0 -0
  551. /package/framework/{v0.6.8 → v0.6.9}/lib/storage/src/local.js +0 -0
  552. /package/framework/{v0.6.8 → v0.6.9}/lib/storage/src/main.js +0 -0
  553. /package/framework/{v0.6.8 → v0.6.9}/lib/storage/src/meta-store.js +0 -0
  554. /package/framework/{v0.6.8 → v0.6.9}/lib/storage/src/s3.js +0 -0
  555. /package/framework/{v0.6.8 → v0.6.9}/lib/storage/src/util.js +0 -0
  556. /package/framework/{v0.6.8 → v0.6.9}/lib/storage-store.js +0 -0
  557. /package/framework/{v0.6.8 → v0.6.9}/lib/swig-filters/README.md +0 -0
  558. /package/framework/{v0.6.8 → v0.6.9}/lib/swig-filters/package.json +0 -0
  559. /package/framework/{v0.6.8 → v0.6.9}/lib/swig-filters/src/main.js +0 -0
  560. /package/framework/{v0.6.8 → v0.6.9}/lib/swig-resolver/package.json +0 -0
  561. /package/framework/{v0.6.8 → v0.6.9}/lib/swig-resolver/src/main.js +0 -0
  562. /package/framework/{v0.6.8 → v0.6.9}/lib/template-loaders/package.json +0 -0
  563. /package/framework/{v0.6.8 → v0.6.9}/lib/template-loaders/src/loaders/http.js +0 -0
  564. /package/framework/{v0.6.8 → v0.6.9}/lib/template-loaders/src/loaders/memory.js +0 -0
  565. /package/framework/{v0.6.8 → v0.6.9}/lib/template-loaders/src/main.js +0 -0
  566. /package/framework/{v0.6.8 → v0.6.9}/lib/url/README.md +0 -0
  567. /package/framework/{v0.6.8 → v0.6.9}/lib/url/index.js +0 -0
  568. /package/framework/{v0.6.8 → v0.6.9}/lib/url/routing.json +0 -0
  569. /package/framework/{v0.6.8 → v0.6.9}/lib/uuid/package.json +0 -0
  570. /package/framework/{v0.6.8 → v0.6.9}/lib/uuid/src/main.js +0 -0
  571. /package/framework/{v0.6.8 → v0.6.9}/lib/validator.js +0 -0
  572. /package/framework/{v0.6.8 → v0.6.9}/lib/watcher/package.json +0 -0
  573. /package/framework/{v0.6.8 → v0.6.9}/lib/watcher/src/main.js +0 -0
  574. /package/framework/{v0.6.8 → v0.6.9}/lib/ws-framing/package.json +0 -0
  575. /package/framework/{v0.6.8 → v0.6.9}/lib/ws-framing/src/main.js +0 -0
  576. /package/framework/{v0.6.8 → v0.6.9}/lib/ws-query/package.json +0 -0
  577. /package/framework/{v0.6.8 → v0.6.9}/lib/ws-query/src/main.js +0 -0
  578. /package/framework/{v0.6.8 → v0.6.9}/lib/ws-session/package.json +0 -0
  579. /package/framework/{v0.6.8 → v0.6.9}/lib/ws-session/src/main.js +0 -0
package/llms.txt CHANGED
@@ -797,7 +797,7 @@ Dev-mode query instrumentation captures every database query tied to the current
797
797
  12. **Static file caching — production vs dev**: In production (`NODE_ENV_IS_DEV` not set), `handleStatics` sends `ETag` (`"<size>-<mtime>"`) and `Last-Modified` on every 200 response. If the browser resends `If-None-Match` or `If-Modified-Since`, a **304 Not Modified** (no body) is returned. `If-None-Match` takes precedence. In dev mode (`isCacheless=true`), 304 is never sent — all statics are served fresh with `cache-control: no-cache, no-store, must-revalidate`. The ETag format matches Express/serve-static: `"<byteSize>-<mtimeMs>"`. Both HTTP/1.x and HTTP/2 paths implement the same logic. **Directory-to-index redirect (former #218, #B72, 2026-07-06):** a static request whose resolved filename is a DIRECTORY containing an `index.html` redirects to `<url>index.html` with an unconditional **301 on BOTH protocols, dev or not** — the HTTP/1.x branch used to issue its `writeHead(301)` only inside the dev gate, so outside dev it answered 200 with a `Location` header browsers ignore (a blank page instead of the index; the HTTP/2 sibling always sent `:status` 301, and http/1.1 + http IS the scaffold default). The no-cache header set stays dev-only. The branch requires the directory to CONTAIN an index.html — without one the request 403s before the redirect, which is why a probe against an asset directory with no index misses it. Static dispatch is engine-agnostic (the built-in engine serves only its own endpoints and cache-hits itself; everything else, statics included, flows through the shared pipeline).
798
798
  33. **`query()` callback — `false` is the success sentinel** — `self.query()` calls `callback(false, data)` on success, not `callback(null, data)`. Use `if (err)` as the error guard — it works because `false` is falsy. Never check `err === null` (always false on success). Never use `err = merge(err, {...})` on a real Error object — `merge()` destroys `message` and `stack`. Assign extra properties directly: `err.session = userSession`.
799
799
 
800
- 50. **Leak-scan & public-surface discipline — tooling, sidecar pattern, recovery** — covers four leak surfaces (commit messages, changie YAML bodies, JSDoc in shipped source, tracked-script body content); five layered defenses (`.gitignore` → `.npmignore` → pre-commit → CI scan → publish prepack); `.gitignore` and `.npmignore` are independent files and patterns are NOT auto-synced (every new gitignored file must be cross-checked against both); `package.json files` whitelist bypasses `.npmignore` for nested paths (#S4 audit, 80MB → 6.9MB tarball decision); sidecar pattern (`script/.private-tokens.json` + shared loader) for tooling that names what it scans for; `git filter-repo` operational gotchas (prior-run prompt, origin removal, committer-date SHA churn, local-only-tag false positive, `--replace-text` ≠ `--replace-message`); GitHub branch protection `allow_force_pushes: false` blocks ALL force-pushes including admin (subfield endpoint returns 404; web UI toggle has been observed to silently no-op); cwd persists across the local-tool harness's Bash tool calls (one chained `cd` affects subsequent calls — silent wrong-repo trap); doc-rule → harness hook escalation when a documented HARD RULE leaks twice on the same surface within weeks.
800
+ 50. **Leak-scan & public-surface discipline — tooling, sidecar pattern, recovery** — covers four leak surfaces (commit messages, changie YAML bodies, JSDoc in shipped source, tracked-script body content); five layered defenses (`.gitignore` → `.npmignore` → pre-commit → CI scan → publish prepack); `.gitignore` and `.npmignore` are independent files and patterns are NOT auto-synced (every new gitignored file must be cross-checked against both); `package.json files` whitelist bypasses `.npmignore` for nested paths (#S4 audit, 80MB → 6.9MB tarball decision); sidecar pattern (`script/.private-tokens.json` + shared loader) for tooling that names what it scans for; `git filter-repo` operational gotchas (prior-run prompt, origin removal, committer-date SHA churn, local-only-tag false positive, `--replace-text` ≠ `--replace-message`); GitHub branch protection `allow_force_pushes: false` blocks ALL force-pushes including admin (subfield endpoint returns 404; web UI toggle has been observed to silently no-op); cwd persists across the local-tool harness's Bash tool calls (one chained `cd` affects subsequent calls — silent wrong-repo trap); doc-rule → harness hook escalation when a documented HARD RULE leaks twice on the same surface within weeks. **A SCAFFOLDED project's ignore file is a separate surface from the framework's own, and it was empty until 0.6.9 (#B258/#B291):** `core/template/_gitignore` shipped in every tarball with ZERO consumers — the `_`→`.` rename its prefix implies was never implemented — so `gina project:add` produced projects with no `.gitignore` at all, and the `.env`/`.env.*`/`*.env` globs that template carries protected nothing. `project:add` now copies it to `<project>/.gitignore` **only when the project has none** (skip-if-exists, so a user's own file is never replaced or appended to, and re-running the command is a no-op), mode `0o644`, and never fatally: a template read failure warns and the scaffold still completes. It deliberately does NOT reuse `lib.generator.createFileFromTemplateSync`, which unlinks the target first (so it cannot express skip-if-exists) and chmods `0755`. Note the glob shape is the point — a bare `.env` matches neither `secrets.env` nor `.env.production`, which is why all three forms plus the `!.env.example` negations ship together.
801
801
 
802
802
  54. **Bundle `config/*.json` shared → bundle overlay is key-level deep merge, not whole-file replace** — `core/config.js:1766` runs `merge(sharedMain, jsonFile, true)` AFTER the bundle file loads. Shared-only keys are preserved; bundle wins on conflicting keys. Applies to `connectors.json`, `env.json`, `settings.json`, and every other per-bundle config file that also has a `shared/config/<same-file>.json` sibling. Reading only the bundle file for config inspection is a silent half-picture — any CLI or tool that inspects per-bundle config must reproduce the overlay merge.
803
803
 
@@ -809,7 +809,7 @@ Dev-mode query instrumentation captures every database query tied to the current
809
809
 
810
810
  80. **CSRF — three-phase protection (`#CSRF1` Session plugin / `#CSRF2` signed double-submit token / `#CSRF3` Origin pre-filter)** — Session plugin hardens `express-session` cookie defaults from `settings.json > session.cookie.{sameSite, httpOnly, secure}` (opt-in, one-line bundle bootstrap swap, never transparent because bundles legitimately set `httpOnly: false`); signed token middleware uses `crypto.timingSafeEqual` HMAC-SHA256 bound to `req.session.id` with per-route `routing.json > "csrfExempt": true` opt-out (see `routing-and-http2.md § "Per-route flags"` for `req.routing.csrfExempt` two-step propagation: `lib/routing` extracts → `core/server.js` hoists, top-level on `req.routing` not under `param.*`); Origin pre-filter folded INSIDE `gina.plugins.Csrf()` BEFORE token verify on mutating methods, allowlist via `settings.json > csrf.allowedOrigins` (empty defaults to `[bundleHostname]` auto-derived). Negative-invariant lock: matching token + mismatching Origin still 403s — token layer ≠ Origin layer. Implementation traps from the trilogy: plan-vs-shipped attribute drift (downstream commits must `grep -n '<attribute>' <upstream-source-file>` before referencing — source on develop wins, not the plan); CI flake-vs-regression triage (same-message-different-SHA pairs need `git diff --stat A..B` first); source-inspection `indexOf` matches the function DEFINITION before the call site (use unique assignment LHS like `requestOrigin = parseRequestOrigin(req)`); `Origin: "null"` is a real browser value (sandboxed iframes / `file://`) and needs an explicit `s === 'null'` guard so the parser falls through to Referer instead of 403'ing for "origin not allowed".
811
811
 
812
- 98. **Secrets — `${secret:KEY}` config placeholders + the `secrets:scan`/`secrets:check` introspection CLI (absorbs former #135).** `lib/secrets` substitutes `${secret:KEY}` at config-load (`core/config.js::loadBundleConfig`, post-merge per bundle), resolving each key from the FRAMEWORK environment first (`getEnvVar`) and falling back to `process.env[KEY]`. **That two-tier read is load-bearing, not defensive (0.6.3, #B156):** the CLI MOVES every `GINA_*`/`USER_*`/`VENDOR_*` key OUT of `process.env` into `process.gina` (`filterArgs`, `utils/helper.js`), so a `${secret:GINA_*}` placeholder was previously unresolvable in ANY CLI-loaded config — `mcp.json` (the published example), the connector commands, `audit:verify` — and failed closed with a refusing boot. Non-`GINA_` keys are untouched by the sweep and resolve as before. The same idiom now backs the CSRF `GINA_CSRF_SECRET` tier and the MCP transport's `GINA_BIND_HOST`/`GINA_DIR` reads (#B157). ANCHORED: the regex matches only when the ENTIRE string value is the placeholder — mixed strings (`"prefix-${secret:K}-suffix"`) pass through unchanged; non-string scalars untouched; nested objects/arrays descend. **Fail-closed:** an unset/empty env var throws a GENERIC `Secret resolution failed` — the key name rides only a non-enumerable `_ginaSecretKey`, surfaced at debug level by the config-load catch naming key + `<bundle>/<env>:<scope>` (#B42), so consumer-visible surfaces never carry key names. Secret rotation needs a process restart (the resolver re-runs on `config.refresh()`, but the env is inherited from container init). Pluggable backend interface (`{resolve(key)}` — env-only by default, env-over-file once `secrets.file` is declared); `getResolvedPaths()` WeakMap path tracking for future log-redaction. **FILE SYNTAX — one parser (`lib/secrets/src/env-file.js::parseEnv`) serves BOTH readers (`secrets:check` + the file backend), so the gate cannot green-light a file the runtime reads differently.** It targets what a POSIX shell `source`s, since the documented entrypoint (`set -a; . secrets.env; set +a`) is a THIRD reader the module cannot unify by construction. **#B269 (0.6.4) — a trailing `#` comment is now stripped** (previously swallowed into the value): the strip fires only when the `#` is preceded by whitespace AND outside quotes, because `abc#def` is a literal hash to a shell and `"abc # def"` is a quoted one — so quoting is a COMPLETE escape hatch, and the strip runs on the RAW pre-trim value (trimming first makes `KEY= # c` and `KEY=#c` indistinguishable while the shell reads them as `''` vs `'#c'`). Unquoting applies AFTER the strip, so `KEY="abc" # note` is `abc`, not `"abc"` — previously the comment defeated the `/^".*"$/` test and the quotes became part of the credential. Consequence to expect: `KEY= # comment` is now EMPTY, and empty counts as unset in both readers (`typeof v === 'string' && v !== ''`), so it fails closed and `secrets:check` reports UNSET where it previously reported a FALSE-GREEN SET. Two divergences REMAIN by decision — `KEY = value` (accepted+trimmed here, a shell errors) and `KEY="a"b"` (`a"b` here, `ab` in a shell) — so do NOT restate the parser as "whatever a POSIX shell does". **`getRequiredKeys(config)`** is the read-only, non-throwing sibling backing the OFFLINE CLI: `gina secrets:scan [<bundle>] [@project]` (which keys each bundle requires, grouped by file — sources derived from the manifest's `src`, never a guessed path) + `gina secrets:check` (marks SET/UNSET, exits non-zero — the CI gate), with `--scope=<s>` overlaying `config_<s>/` dirs READ-ONLY (the runtime loader stays scope-agnostic about config DIRS) and `--env-file` standing in for the ENV TIER. **#B263 (0.6.4) — `check` now reads BOTH runtime tiers, in order:** env, then the bundle's declared `secrets.file` chain resolved through the SAME global `whisper` + the shared `parseEnvFile`, so it can no longer report UNSET (and exit non-zero) for a key the bundle would have booted with — that false RED, not a false green, was the dominant real-world defect, since adding a source can only turn UNSET→SET. Parity is BY CONSTRUCTION: `homedir` comes from the project's own `env.json` (`<bundle>.<env>`) else the template's `~/.<projectName>`, NEVER `projects.json`'s `homedir` (a CLI-level field `config.js` never reads). A reps key is seeded only from a NON-EMPTY string — an absent key leaves its token verbatim and is caught loudly, but an EMPTY one substitutes SILENTLY (measured: `scope:''` → `/h//f.env`, which collapses to the base path on POSIX with no guard firing; whisper's pass 2 `dict[k]||s` leaves it, pass 3 `!==undefined` rescues it). `${scope}`/`${env}` are LAUNCH-time values the CLI cannot read (runtime prefers `NODE_SCOPE`/`NODE_ENV`), so the report NAMES the values it assumed, `--env` was added to pick the env block, and an unresolvable path is reported + its tier skipped rather than statted — under-reading can only make the gate stricter than the runtime, never laxer. **#B266 (`74e52cda`) — the reps dict must carry every token a REAL chain uses, not the ones the examples use.** The first consumer config (`${homedir}/v${projectVersionMajor}/credentials/[${scope}/]secrets.env`) resolved at runtime but NOT in `check`, re-opening the #B263 false-RED through three holes: (a) **version tokens absent** — the runtime does not list them in the bundle-config dict either, it gets them because `core/template/conf/env.json` declares `projectVersion`/`projectVersionMajor` as per-bundle SCALARS which the `:2356` harvest then copies in, so **reading only the `:2296` dict literal shows no version tokens and looks complete**; now seeded from the manifest; (b) **`${scope}` had no default** — now falls back to the project default via a SEPARATE `self.scopeAssumed` slot, because defaulting `self.scopeName` would start applying the `config_<scope>/` overlay to every bare invocation (two axes, one name); (c) **`~/.undefined`** — `buildReps` used `self.projectName`, undefined on the all-projects branch, yielding a real stat-able wrong path; now derived from the project PATH. Declaration resolution is pinned (`5a80b1a1`): bundle `secrets.file` REPLACES shared's (arrays never concatenate), `secrets:{}` INHERITS, `secrets:{file:null}` is the opt-out, and `--env-file` stays TOP tier (it stands in for the environment; demoting it below the files would model something the runtime never does). Each key's report names the tier that satisfied it. ⚠️ The other `secrets.resolve()` callers pass NO backend and stay env-only (`bundle:mcp-start`, `connector:infer`/`test`/`models` — #B264). Design of record: the framework RESOLVES secrets, the deployment layer STORES them. Since 0.6.4 it may additionally READ declared plaintext files as the LOWEST-precedence tier — opt-in `settings.secrets.file` in a bundle's `settings.json`, one path or an array written with the usual config tokens (`["${homedir}/secrets.env", "${homedir}/${scope}/secrets.env"]`), later entries winning. Files layer UNDER the environment (`getEnvVar` → `process.env` → files → throw), which INVERTS the usual `.env` intuition and is deliberate: every production delivery path (K8s `secretRef`, ECS task secrets, `sops exec-env`, a CI export) arrives via the environment, so a file that won could let a stale plaintext copy shadow the real credential. ⚠️ Precisely, it is a NON-EMPTY env value that wins (#B268, `90518bec`): a SET-but-EMPTY variable counts as ABSENT and the file fills it — deliberate and kept, because `environment: ["X=${X}"]` with the outer var unset puts an empty-but-SET var in the container (measured) and refusing would break working setups — but since a failed `export X="$(fetch …)"` produces that same shape, the fall-through WARNS, naming the key and never the value, at `warn` so the default `info` hierarchy keeps it. Undeclared ⇒ byte-identical to env-only; a declared-but-ABSENT file contributes nothing, but one that EXISTS and cannot be READ is FATAL (#B267, `90518bec`) — collapsing both to `null` let a `[base, per-scope]` chain whose per-scope file lost read permission boot silently on the SHARED credential (measured: `production_password` → `shared_dev_password`), announced only by a debug line MISLABELLED `ABSENT` which `info` suppresses; `env-file` now also exports `readEnvFile` (reports the errno — ENOENT vs EACCES/EISDIR; a dangling symlink is ENOENT, correctly absent), the backend refuses on anything non-ENOENT naming path + code, and `secrets:check` reports `UNREADABLE` so the CI gate and the runtime cannot disagree about a file neither can open; resolution stays fail-closed; paths carrying an unresolved `${…}` or a `${secret:…}` are rejected at boot. Selection reads `content.settings`, NOT `settings` — historically the only copy with tokens resolved, because `config.js` bound `.settings` BEFORE the token-substitution pass and `.content` after it, and substitution returns a NEW object rather than rewriting the original. **Since #B257 (0.6.x) the `.settings` alias is re-pointed at the post-substitution copy, so for a config built by `loadBundleConfig` the two are now the SAME object** and either read would serve; `content.settings` stays the canonical one because `selectBackend` accepts any config-shaped object, including ones assembled by other paths. ⚠️ The old rationale was never accurate AS STATED (#B273) and must not be restated: an EARLIER substitution pass already resolved `${homedir}` / `${scope}` in both copies, so what actually diverged was only the tokens pass 2 is the FIRST to know — `${bundlePath}`, `${libPath}`, `${publicPath}`, `${handlersPath}`, `${mountPath}`, `${gina}`, `${project}`, `${root}`, `${source}`, `${<name>Port}`, `${templates}`/`${html}`/`${theme}` and the `:2356` scalar harvest. `${secret:…}` was never affected in either copy, since `secrets.resolve()` walks the config IN PLACE and so reaches both subtrees. Consumer-visible consequence of the fix: a `getConfig().settings.<x>` read that used to hand back a literal `'${libPath}/…'` now returns the resolved path.It does NOT decrypt: for SOPS/Vault/KMS keep decrypting at the container entrypoint — a structural constraint, not a preference, since `lib/secrets` is fully synchronous and `config.js` holds the #P33 sync-init contract, so a network-backed backend would put a KMS round-trip inside a synchronous boot path. (The scope-agnostic note above is about `config_<s>/` DIRS, which the runtime still does not overlay; a secrets FILE path may be scope-varying via `${scope}`.) Consumers: `Csrf` (`settings.csrf.secret`), `bundle:mcp-start` (re-resolves `mcp.json` post-parse), `auth.machine` caller keys.
812
+ 98. **Secrets — `${secret:KEY}` config placeholders + the `secrets:scan`/`secrets:check` introspection CLI (absorbs former #135).** `lib/secrets` substitutes `${secret:KEY}` at config-load (`core/config.js::loadBundleConfig`, post-merge per bundle), resolving each key from the FRAMEWORK environment first (`getEnvVar`) and falling back to `process.env[KEY]`. **That two-tier read is load-bearing, not defensive (0.6.3, #B156):** the CLI MOVES every `GINA_*`/`USER_*`/`VENDOR_*` key OUT of `process.env` into `process.gina` (`filterArgs`, `utils/helper.js`), so a `${secret:GINA_*}` placeholder was previously unresolvable in ANY CLI-loaded config — `mcp.json` (the published example), the connector commands, `audit:verify` — and failed closed with a refusing boot. Non-`GINA_` keys are untouched by the sweep and resolve as before. The same idiom now backs the CSRF `GINA_CSRF_SECRET` tier and the MCP transport's `GINA_BIND_HOST`/`GINA_DIR` reads (#B157). ANCHORED: the regex matches only when the ENTIRE string value is the placeholder — mixed strings (`"prefix-${secret:K}-suffix"`) pass through unchanged; non-string scalars untouched; nested objects/arrays descend. **Fail-closed:** an unset/empty env var throws a GENERIC `Secret resolution failed` — the key name rides only a non-enumerable `_ginaSecretKey`, surfaced at debug level by the config-load catch naming key + `<bundle>/<env>:<scope>` (#B42), so consumer-visible surfaces never carry key names. Secret rotation needs a process restart (the resolver re-runs on `config.refresh()`, but the env is inherited from container init). Pluggable backend interface (`{resolve(key)}` — env-only by default, env-over-file once `secrets.file` is declared). **The two tiers are read INDEPENDENTLY, never joined by `||` (0.6.9, #B270):** `filterArgs` stores swept values as REAL booleans, so a truthy boolean used to satisfy the short-circuit and `process.env` was never consulted — the file tier then beat a SET environment variable, inverting the precedence the file backend exists to enforce. Only a non-empty STRING from the framework tier wins; a boolean, number, `undefined` or `''` falls through. `getResolvedPaths()` gives the dotted paths substituted in a config, but it is **not** a general redaction substrate (0.6.9, #B274): the paths address the config object and the lookup is keyed on its IDENTITY, so a consumer must hold the very object `resolve()` mutated — which rules out the Inspector, whose payload is a structural clone of the controller's VIEW data (`{gina, user}`), a different object in a different coordinate space. A surface holding VALUES rather than the config needs a value-based pass; `core/connectors/param-redact.js` exists for exactly that reason. **`secrets.file` shape guards (0.6.9):** `[]` still disables the tier like `null` but now WARNS rather than doing it silently (#B271 — an operator emptying the array to drop one layer drops the whole tier; boot is not refused, since an empty list genuinely means "no files"); a whitespace-only entry is REFUSED (`minLength: 1` counts a space, so `[" "]` used to build a tier that could never resolve anything); and a path containing an empty segment (`//`) is refused (#B272 — such a path does not name the file it appears to, since POSIX reads `<a>//<b>` as `<a>/<b>`; the DANGEROUS cause is a `${…}` token that resolved to EMPTY, leaving no token for the unresolved-token guard and silently reading the file one directory UP — `${homedir}/${scope}/secrets.env` with an empty scope — while the BENIGN cause is a token with a trailing slash such as an operator's `GINA_HOMEDIR=/opt/gina/`, which nothing in the path chain normalises. The two are indistinguishable once assembled, so boot refuses on both and the error names both causes rather than asserting one it cannot know; the benign case is a one-character fix). The schema gains `minItems: 1` as an editor-facing hint — no runtime validator loads `schema/settings.json`, so `main.js` is the enforcement. **FILE SYNTAX — one parser (`lib/secrets/src/env-file.js::parseEnv`) serves BOTH readers (`secrets:check` + the file backend), so the gate cannot green-light a file the runtime reads differently.** It targets what a POSIX shell `source`s, since the documented entrypoint (`set -a; . secrets.env; set +a`) is a THIRD reader the module cannot unify by construction. **#B269 (0.6.4) — a trailing `#` comment is now stripped** (previously swallowed into the value): the strip fires only when the `#` is preceded by whitespace AND outside quotes, because `abc#def` is a literal hash to a shell and `"abc # def"` is a quoted one — so quoting is a COMPLETE escape hatch, and the strip runs on the RAW pre-trim value (trimming first makes `KEY= # c` and `KEY=#c` indistinguishable while the shell reads them as `''` vs `'#c'`). Unquoting applies AFTER the strip, so `KEY="abc" # note` is `abc`, not `"abc"` — previously the comment defeated the `/^".*"$/` test and the quotes became part of the credential. Consequence to expect: `KEY= # comment` is now EMPTY, and empty counts as unset in both readers (`typeof v === 'string' && v !== ''`), so it fails closed and `secrets:check` reports UNSET where it previously reported a FALSE-GREEN SET. Two divergences REMAIN by decision — `KEY = value` (accepted+trimmed here, a shell errors) and `KEY="a"b"` (`a"b` here, `ab` in a shell) — so do NOT restate the parser as "whatever a POSIX shell does". **`getRequiredKeys(config)`** is the read-only, non-throwing sibling backing the OFFLINE CLI: `gina secrets:scan [<bundle>] [@project]` (which keys each bundle requires, grouped by file — sources derived from the manifest's `src`, never a guessed path) + `gina secrets:check` (marks SET/UNSET, exits non-zero — the CI gate), with `--scope=<s>` overlaying `config_<s>/` dirs READ-ONLY (the runtime loader stays scope-agnostic about config DIRS) and `--env-file` standing in for the ENV TIER. **#B263 (0.6.4) — `check` now reads BOTH runtime tiers, in order:** env, then the bundle's declared `secrets.file` chain resolved through the SAME global `whisper` + the shared `parseEnvFile`, so it can no longer report UNSET (and exit non-zero) for a key the bundle would have booted with — that false RED, not a false green, was the dominant real-world defect, since adding a source can only turn UNSET→SET. Parity is BY CONSTRUCTION: `homedir` comes from the project's own `env.json` (`<bundle>.<env>`) else the template's `~/.<projectName>`, NEVER `projects.json`'s `homedir` (a CLI-level field `config.js` never reads). A reps key is seeded only from a NON-EMPTY string — an absent key leaves its token verbatim and is caught loudly, but an EMPTY one substitutes SILENTLY (measured: `scope:''` → `/h//f.env`, which collapses to the base path on POSIX with no guard firing; whisper's pass 2 `dict[k]||s` leaves it, pass 3 `!==undefined` rescues it). `${scope}`/`${env}` are LAUNCH-time values the CLI cannot read (runtime prefers `NODE_SCOPE`/`NODE_ENV`), so the report NAMES the values it assumed, `--env` was added to pick the env block, and an unresolvable path is reported + its tier skipped rather than statted — under-reading can only make the gate stricter than the runtime, never laxer. **#B266 (`74e52cda`) — the reps dict must carry every token a REAL chain uses, not the ones the examples use.** The first consumer config (`${homedir}/v${projectVersionMajor}/credentials/[${scope}/]secrets.env`) resolved at runtime but NOT in `check`, re-opening the #B263 false-RED through three holes: (a) **version tokens absent** — the runtime does not list them in the bundle-config dict either, it gets them because `core/template/conf/env.json` declares `projectVersion`/`projectVersionMajor` as per-bundle SCALARS which the `:2356` harvest then copies in, so **reading only the `:2296` dict literal shows no version tokens and looks complete**; now seeded from the manifest; (b) **`${scope}` had no default** — now falls back to the project default via a SEPARATE `self.scopeAssumed` slot, because defaulting `self.scopeName` would start applying the `config_<scope>/` overlay to every bare invocation (two axes, one name); (c) **`~/.undefined`** — `buildReps` used `self.projectName`, undefined on the all-projects branch, yielding a real stat-able wrong path; now derived from the project PATH. Declaration resolution is pinned (`5a80b1a1`): bundle `secrets.file` REPLACES shared's (arrays never concatenate), `secrets:{}` INHERITS, `secrets:{file:null}` is the opt-out, and `--env-file` stays TOP tier (it stands in for the environment; demoting it below the files would model something the runtime never does). Each key's report names the tier that satisfied it. ⚠️ The other `secrets.resolve()` callers pass NO backend and stay env-only (`bundle:mcp-start`, `connector:infer`/`test`/`models` — #B264). Design of record: the framework RESOLVES secrets, the deployment layer STORES them. Since 0.6.4 it may additionally READ declared plaintext files as the LOWEST-precedence tier — opt-in `settings.secrets.file` in a bundle's `settings.json`, one path or an array written with the usual config tokens (`["${homedir}/secrets.env", "${homedir}/${scope}/secrets.env"]`), later entries winning. Files layer UNDER the environment (`getEnvVar` → `process.env` → files → throw), which INVERTS the usual `.env` intuition and is deliberate: every production delivery path (K8s `secretRef`, ECS task secrets, `sops exec-env`, a CI export) arrives via the environment, so a file that won could let a stale plaintext copy shadow the real credential. ⚠️ Precisely, it is a NON-EMPTY env value that wins (#B268, `90518bec`): a SET-but-EMPTY variable counts as ABSENT and the file fills it — deliberate and kept, because `environment: ["X=${X}"]` with the outer var unset puts an empty-but-SET var in the container (measured) and refusing would break working setups — but since a failed `export X="$(fetch …)"` produces that same shape, the fall-through WARNS, naming the key and never the value, at `warn` so the default `info` hierarchy keeps it. Undeclared ⇒ byte-identical to env-only; a declared-but-ABSENT file contributes nothing, but one that EXISTS and cannot be READ is FATAL (#B267, `90518bec`) — collapsing both to `null` let a `[base, per-scope]` chain whose per-scope file lost read permission boot silently on the SHARED credential (measured: `production_password` → `shared_dev_password`), announced only by a debug line MISLABELLED `ABSENT` which `info` suppresses; `env-file` now also exports `readEnvFile` (reports the errno — ENOENT vs EACCES/EISDIR; a dangling symlink is ENOENT, correctly absent), the backend refuses on anything non-ENOENT naming path + code, and `secrets:check` reports `UNREADABLE` so the CI gate and the runtime cannot disagree about a file neither can open; resolution stays fail-closed; paths carrying an unresolved `${…}` or a `${secret:…}` are rejected at boot. Selection reads `content.settings`, NOT `settings` — historically the only copy with tokens resolved, because `config.js` bound `.settings` BEFORE the token-substitution pass and `.content` after it, and substitution returns a NEW object rather than rewriting the original. **Since #B257 (0.6.x) the `.settings` alias is re-pointed at the post-substitution copy, so for a config built by `loadBundleConfig` the two are now the SAME object** and either read would serve; `content.settings` stays the canonical one because `selectBackend` accepts any config-shaped object, including ones assembled by other paths. ⚠️ The old rationale was never accurate AS STATED (#B273) and must not be restated: an EARLIER substitution pass already resolved `${homedir}` / `${scope}` in both copies, so what actually diverged was only the tokens pass 2 is the FIRST to know — `${bundlePath}`, `${libPath}`, `${publicPath}`, `${handlersPath}`, `${mountPath}`, `${gina}`, `${project}`, `${root}`, `${source}`, `${<name>Port}`, `${templates}`/`${html}`/`${theme}` and the `:2356` scalar harvest. `${secret:…}` was never affected in either copy, since `secrets.resolve()` walks the config IN PLACE and so reaches both subtrees. Consumer-visible consequence of the fix: a `getConfig().settings.<x>` read that used to hand back a literal `'${libPath}/…'` now returns the resolved path.It does NOT decrypt: for SOPS/Vault/KMS keep decrypting at the container entrypoint — a structural constraint, not a preference, since `lib/secrets` is fully synchronous and `config.js` holds the #P33 sync-init contract, so a network-backed backend would put a KMS round-trip inside a synchronous boot path. (The scope-agnostic note above is about `config_<s>/` DIRS, which the runtime still does not overlay; a secrets FILE path may be scope-varying via `${scope}`.) Consumers: `Csrf` (`settings.csrf.secret`), `bundle:mcp-start` (re-resolves `mcp.json` post-parse), `auth.machine` caller keys.
813
813
 
814
814
  125. **CLI handler authoring & operations — consolidated** (replaces individual entries #17, #23, #24, #55, #56, #58, #59, #60, #62, #63, #102, #112; plus #161, #166, #167, #181, #182, #183, #185, #199, #201, #202, #203 folded 2026-07-05):
815
815
  - **CLI stubs** — `gina --status` / `gina -t` appear in help.txt or docs but have no handler (not in `aliases.json`); tracked in `ROADMAP.md § CLI`; never suggest to users without checking the handler file first. `bundle:status` / `project:status` / `minion:list` / `minion:kill` / `protocol:remove` / `bundle:copy` (+ `cp` alias) / `bundle:rename` shipped 0.4.1-alpha.2; `project:move` / `project:backup` / `project:restore` / `framework:update` / `framework:man` (+ `project:man` / `bundle:man` / `service:man`) — the **CLI Tier 3** finals — shipped 0.5.x (full coverage in the two CLI Tier 3 sub-bullets below). Both minion commands are run-dir-driven process-truth (the "minion" abstraction is half-wired — nothing sets `process.isMinion` or writes `*minion*.pid`, so a minion == any running bundle child-process): `minion:list` lists every live `<bundle>@<project>.pid` grouped by project via `lib.cmdStatusFormat`; `minion:kill @<project>` reaps them (hybrid kill-set = run-dir pidfiles + a `ps -ef | grep 'gina: ...@<project>'` sweep for pidfile-less orphans bundle:stop misses), SIGTERM→grace→SIGKILL escalation, `--dry-run` preview, unlinks stale/killed pidfiles, never touches mount symlinks or its own PID. `protocol:remove <bundle> @<project>` reverts a bundle to the project default protocol by deleting ONLY its `server.protocol/scheme/allowHTTP1` override from the bundle's `settings.json` (config.js `:1014/:1020` auto-defaults an absent protocol to `def_protocol`/`def_scheme`); it deliberately does NOT mutate the shared `ports*.json` — `project:add` pre-allocates the full protocol×scheme×env matrix, so the default-protocol port already exists and pruning the set's port would be wrong; a per-env port-presence guard refuses (unless `--force`) when the default-protocol port is missing; `--dry-run` preview, header-preserving JSON rewrite (connector:rm pattern). `bundle:copy <source> <new> @<project>` (+ `cp`) duplicates a bundle under a new name in the SAME project: copies the `src/<source>` tree, then word-boundary-rewrites the name footprint (PascalCase `<Src>`→`<Dst>` for controller class names + lowercase whole-word `<src>`→`<new>` for the gina require-var / `app.json` name / webroot path, `.js`/`.json` files only — embedded tokens like `apiClient` are untouched; a first-bundle webroot `/` is repointed to `/<new>`), allocates a fresh FULL protocol×scheme×env port matrix via the shared `setPorts` (a single-port insert would later emerg in `config.js`, which expects the complete matrix), and clones+repoints the source's manifest entry (`src`/`link`/`releases` target paths). `--dry-run` previews every rewrite site before writing; `--force` overwrites an existing target (its `removeDest` mirrors `bundle:remove`'s deletions). TWO positionals leave `self.name` null (CmdHelper sets it only for a single positional), so the handler reads `self.bundles[0]`/`[1]` directly — which also slips past the `cmd.name`-gated existence guard so the not-yet-registered new name isn't rejected. `bundle:rename <old> <new> @<project>` is the move-sibling — it renames a bundle IN PLACE in the same project (`fs.renameSync` move, NOT a copy) reusing the same `inc/name-rewrite.js` engine but with `fixWebroot:false` (rename moves the only bundle, so there's no first-bundle/collision case; a name-derived `/<old>` webroot is still rewritten by the lowercase pass). Its ports are REKEYED, not reallocated — port NUMBERS are preserved: `ports.json` rewrites the `<old>@<project>/` owner prefix back into the SAME `[protocol][scheme][portKey]` slot (avoiding the two `project/rename.js` bugs: the wrong `[protocol][portKey]` slot, and a project-wide owner replace), then `ports.reverse.json` is rekeyed (`pr[new]=pr[old]; delete pr[old]`) and flipped LAST as the canonical existence record. It REFUSES a running bundle with NO `--force` bypass (`--force` only overwrites an existing dest); the whole multi-surface mutation (symlink → renameSync dir → rewrite tree → env → manifest → ports → ports.reverse) is snapshot-guarded (the `bundle:add` rollback model) so any post-move failure reverses the dir move and restores env/manifest/ports/ports.reverse from in-memory snapshots. NOTE: the ROADMAP's "fix the help.txt remouve typo" item was stale — no such typo existed.
@@ -894,7 +894,7 @@ Dev-mode query instrumentation captures every database query tied to the current
894
894
 
895
895
  209. **FormValidator — engine disambiguation, string inputs, a11y reflection, and live-check message visibility (consolidates former #42/#130/#150/#186/#197).** The live form/data rule engine is `core/plugins/lib/validator/src/form-validator.js` (single source, `isGFFCtx`-branched: runs server-side via `backendInit` AND compiled into the browser bundle) with the client orchestration in `validator/src/main.js` — `framework/v*/lib/validator.js` is a DEAD standalone fluent validator with overlapping `is*` rule names; never edit it for form-rule work (tell them apart fast: the live engine's `isRequired` rejects whitespace-only input, the dead one passes it). Rule bodies must handle STRING inputs — the two DECLARATIVE contexts feed strings (`.value` + urlencoded bodies) — so a typed/numeric rule coerces or parses explicit components, never assumes a typed JS value; but "always a string" is NOT true of every path, and reading it that way shipped #B198 (see below): a JSON request body keeps real Numbers (`JSON.parse` → `req.body`/`req.post`, which the `validator::{}` routing path MERGES into the validated data before spreading array bounds through `apply()`), and `toInteger` leaves `Math.round()`'s real Number on `this.value`, so a `toInteger` → `is*` chain hands the next rule a Number even in the browser — a rule must therefore be correct for a typed value too, not merely tolerant of strings: `isFloat` coerces via `Number()` (#B46); `isDate` builds from explicit mask components + a round-trip check so non-ISO slash masks aren't US-misparsed and impossible dates still reject (#B47); `isDate` returns the FIELD again on its valid path (#B48, 0.5.4 — parsed `Date` preserved on the field's `.value`, the `isDate(mask).format(...)` idiom unchanged), so rule chaining works. An empty value is adjudicated by `isRequired` ALONE (#B78): the per-rule empty-bypass became unconditional on empty (`if (this.value == '')`, its old `!errors['isRequired']` gate dropped) for `isEmail`/`isJsonWebToken`/`isFloat`/`isInList` — each regating `this.valid = isValid && !errors['isRequired']` — and `isString` keeps the field invalid without recording a second message, so a required-empty field shows ONE message (`is required`) not two, optional empty fields still pass, a filled-but-invalid value still reports its own error, and custom `is` was deliberately excluded by #B78 and re-declined by #B82 — an exclusion REVERSED by #B233 (2026-08-03, 0.6.3, `307721f2`): `is` now carries the same canonical strict bypass (`if ( this.value === '' ) { isValid = true; }`) and the same regate, taking the Shape-A population from four rules to FIVE, so a required+EMPTY field carrying an `is` condition records `isRequired` ALONE instead of also collecting a second `Condition not satisfied`. `isBoolean` joined the same contract at #B235 (2026-08-03, 0.6.3, `aa1c2035`), taking that population to SIX: its pre-switch rescue `errors['isRequired'] && this.value == false` was LOOSE (`'' == false`), so a required+EMPTY boolean field LOST its isRequired error and reported `Must be a valid boolean` instead of `Cannot be left empty`; the rule now takes the canonical strict `=== ''` self-pass, and the rescue moves AFTER the accept-set switch gated on the value having been ACCEPTED (`val !== null`) — which keeps the documented unchecked-but-required-toggle case working, since a recognized `false`/`0` is a present answer, while emptiness returns to `isRequired` alone. Paired in the same commit with #B236, the SERVER-side half: the plugin's `getCastedValue` funneled EVERY value on an isBoolean-ruled field through `/^true$/i ? true : false` BEFORE the engine ran (client AND server — `validate` calls `formatFields` unconditionally), so on the server auto path junk validated CLEAN and PERSISTED as `false` — `nope`, the HTML checkbox default `on` (a CHECKED box storing UNchecked), the strings `1`/`0`, `TRUE`/`True` — and the NUMBER 1 stored `false` where the engine reads it as `true`. The pre-cast now survives ONLY in dynamised-rules mode, where a referenced boolean field must splice into a stringified `is` condition as an unquoted operand (measured NECESSARY: deleting it outright breaks a server `$flag === true` condition); the ENGINE is the single adjudicator on every surface, which is what the routing `validator::` surface always enforced and what the published reference already promised. Disclosed both directions: values that silently stored `false` now ERROR, the number 1 flips its stored value `false`→`true` on a verdict that was already valid, an optional blank boolean field now PASSES instead of erroring, and a required blank field's message changes from isBoolean to isRequired. A sibling server-path crash in the same plugin is fixed by #B234 (2026-08-03, 0.6.3, `7c56565d`): `getDynamisedRules` substitutes in two passes, and the SECOND is a DOM fallback re-deriving each splice value from the live element (`$fields[...].value`) — which `backendInit` calls with `$fields = null`, so it threw `TypeError: Cannot read properties of null` on its FIRST iteration for ANY `$` surviving pass 1: a regex end-anchor in an `is` condition, a `$` inside a human-readable message string, or a `$` in any array-rule element after the first. Plain cross-field `$peer === $me` never crashed, because pass 1 consumes tokens that NAME fields. The loop is now gated `$fields && ...`, joining the #B127 precedent one function later; `validate`'s same-text gate is deliberately left UNGUARDED, being reachable only with a live DOM. Residual, disclosed — and since FIXED (#B239): a `$` token in an ARRAY rule's FIRST argument that names no field (`isInList: ['$100']`) threw one site later at `checkFieldAgainstRules`' `d[<token>].value` — NOT DOM-dependent, so it reached the client too. The substitution is now gated on the token resolving to a REAL field (an existing `d` key with a defined `.value` — two clauses, both load-bearing: an engine-METHOD-name collision like `'$isValid'` resolves to a defined key with no `.value`, and pre-fix spliced the string "undefined" into the rule for a silent wrong verdict rather than a crash); anything else stays LITERAL so strict comparison applies (`'$100'` matches its own literal, rejects non-members with the rule's own error; bare-`$` and mixed elements covered). `$` is therefore the engine's RESERVED cross-field sigil: whether an authored `$` stays literal depends on a runtime field-name collision — a token naming a sibling field is consumed UPSTREAM by getDynamisedRules loop 1, substituted with quoting fit for `is`-condition splices, not array elements (`"yes"` with quotes can never match `yes`), so real cross-field refs in array-rule elements are always-invalid, fail-closed, never-worked, undocumented (the reference scopes `$name` to `is` expressions) — tracked as #B240 (demand-gated; the fix is relocating array-element substitution into checkFieldAgainstRules, whose guarded loop is deliberately preserved as the substrate). This reserved-sigil model is also the #DTO2 `$` guard's CURRENT rationale (the crash rationale is retired — deterministic literal semantics are impossible for any `$`, so toRules() refuses at boot rather than validate collision-dependently). The reversal is measured rather than re-argued: the old bypass was gated on `!errors['isRequired']` — off exactly when #B78 wants it on — beside a two-disjunct guard that was DEAD CODE (`x == '' && x != 0` has no witness), optional+empty ALREADY self-passed through the live else-if, and on required+empty the condition is VERDICT-IRRELEVANT (form validity is `getErrors().count()` and `isRequired` has already errored), so form validity and the request payload are identical in both directions and only the message list changes; the "coercion-sensitive" premise had already been retired by #B199's strict test, which leaves `0`/`false`/`null` as operands that still evaluate the condition. #B82 is neither regressed nor retired — its root `getCastedValue` quoting and its `is()` grammar guard stay necessary and reachable with a FILLED host; #B233 only closes that crash path a second time for an empty HOST, whose condition is no longer compiled at all. Same commit drops the dead `_defaultErrorLabels['isApiError']` entry (zero consult sites: the API path assigns the server's message directly and never calls `replace()`) - but a cross-field `is` (`"$a === $b"`) no longer THROWS when the referenced field is empty (#B82): the client dynamised-rules substitution (`getCastedValue` in `main.js`) now renders an empty referenced operand as a quoted `""` (it was spliced RAW, leaving a dangling `"7654321" === ` that `is()`'s binary-comparison grammar `_SCS_BINARY_RE` rejected -> an uncaught throw that aborted the whole-form validity pass and left the submit trigger ungated on an invalid form, breaking the documented `is`+`isRequired` value-confirmation pattern while the confirm field was blank), mirroring `getDynamisedRules`' own sibling substitution default (`: '\"\"'`); `null`/`undefined` stay raw (already valid operands). Hardening: `is()`'s grammar mismatch now FAILS-THE-FIELD (`console.warn`+`isValid=false`) instead of throwing, so a per-keystroke live check can never abort the gate on a residually-unparseable condition (e.g. a field literally valued `"NaN"`, which the root fix leaves raw). Browser-bundled -> prod dist rebuilt; the `#SCS1e`/`#SCS1h` eval-safety pins target the untouched `_SCS_BINARY_RE`/`_scsParseOperand`/regex-literal constructs, so the hardening flips none of them. The form's validity comes from `getErrors().count()` (the surviving `isRequired` error), never the per-field `.valid` flag (whose only error-dropping reader, `setErrors`, is dead). Length bounds are ARITY-sensitive, and the source JSDoc was WRONG about it until 0.6.3: `"isString": [N]` (same for `isInteger`/`isNumber`) supplies `minLength` ONLY — identical in effect to the scalar `N` — because the exact-length branch fires only when `minLength === maxLength`, so an exact length needs `[N, N]`; the stale comment had propagated verbatim into the published reference page, so correct BOTH surfaces when one is found. Those bounds measure the value's STRING FORM (`val.toString().length`) — until 0.6.3 `isInteger` alone measured a bare `val.length`, which is `undefined` on a real Number, so BOTH its bounds were silently inert on every numeric value: no error, no warn, field left `valid` (#B198, a fail-OPEN bypass reachable from a JSON body, a `validator::{}` requirement, or a preceding `toInteger` — the browser included). `isString` reads the same bare `val.length` at two sites and is CORRECT there because a `typeof(val) == 'string'` guard precedes it, so this class of fix is line-scoped: a whole-file replace of the bound expression hits four sites, two of which must not change. One consequence of measuring the string form, intended: a negative number counts its sign toward the length (parity with the same value arriving as a string). The zero-swallow residual #B198 initially left open is CLOSED by #B199 (0.6.3): loose `== ''` emptiness tests conflated `0`/`-0`/`false`/`[]` with the empty string at FIVE sites — the isInteger/isNumber bounds gates AND the isEmail/isJsonWebToken/isFloat empty-bypasses, where a JSON body's `{"email": 0}` validated as a correct email — all five now compare strictly, so only the literal `''` bypasses (the designed empty-is-adjudicated-by-isRequired contract, preserved byte-exactly); `isString` stays loose behind its typeof guard (operators identical for strings), `isInList` was already strict, and `isDate`'s broader `!val` swallow (a silent half-state: `valid` false, NO error recorded, so the form passes) is deliberately untouched. STILL OPEN sibling (#B200): a TRUTHY non-string in an isEmail/isJsonWebToken field (`{"email": 123}`) hits an unguarded `.toLowerCase()` and the rule driver RE-THROWS, killing the whole validation run — the falsy/truthy non-string space is partitioned between the fixed bug and this one. Custom validators (`bundle/validators/<name>/main.js`) are a BROWSER-ONLY affordance, NEVER a server-side guarantee: the server gate reads `getContext('gina').forms` while the loop it guards reads a bare `gina` that is undefined in Node, so a custom rule never attaches server-side and the engine then silently skips the unknown rule name with no warn — re-validate such constraints in the action. (Publishing that context without also fixing the loop would make EVERY validator construction throw, including for bundles shipping no custom validators.) A rule-body edit needs a prod dist rebuild AND flips the section-locked characterization tests by design. Editing trap: `form-validator.js` embeds hidden NO-BREAK SPACE bytes (U+00A0) where a normal space appears inside several `||`/ternary sequences, so a literal-space find/replace spanning one silently fails — patch such regions with a byte-scoped script over clean-ASCII substrings, not a space-spanning match. The blur-time global validation pass sets submit-button state but renders errors ONLY for the touched field — untouched invalid fields stay quiet until interacted-with or submit. Accessibility (#A11Y1): the rule-agnostic chokepoint `handleErrorsDisplay` reflects committed errors into `aria-invalid="true"` (gated on committed-not-warning; `"false"` on clear mirrors native `ValidityState` so it agrees with `:user-invalid`; hidden fields skipped), auto-wires `aria-errormessage` to a gina-owned message div UNLESS the consumer provided their own, focuses the first DOM-order invalid field on a failed submit, and announces blur-time errors via a per-form visually-hidden `aria-live="polite"` region; the per-field aria passes fire only under live-check — the always-on submit pass covers every bound form regardless. Live-region LIFECYCLE (#A11Y2): creation is split out of the announcer into `ensureA11yLiveRegion($form)` and called from `bindForm` — the chokepoint every registration path funnels through — so the region is in the a11y tree from BIND time. It previously created, inserted AND populated the region in one synchronous tick, which reaches assistive tech as a single mutation batch on a node it has never observed, so the FIRST announcement per form (the one that matters most) was the one least likely to be spoken while every later one worked. The region is a CHILD OF THE FORM on purpose: a popin renders its form inside a native `<dialog>` opened with `showModal()`, which leaves everything outside the top layer inert, so a body-level region would go unspoken for exactly the forms that live in popins — the placement is an accessibility constraint, not a convenience. The price of that choice is that a subtree replacement (`$el.innerHTML =` on a popin re-render, a nav fragment swap) destroys it, so `ensureA11yLiveRegion` is create-OR-RECOVER and also re-homes a region whose form node was replaced; any region created or re-homed at announce time is marked fresh and defers its first write one macrotask, so insertion and mutation land in different ticks — that deferral is what stops a recovery from silently repeating the defect, and it is load-bearing because a re-render does NOT always re-bind (`validateFormById` early-returns on an already-registered id, and a multi-form popin's teardown loop splices while iterating so it skips every odd-indexed form). An already-bound region writes synchronously, unchanged; while a deferred write is pending a newer error replaces the pending text so the LATEST message wins, and the timer re-enters the announcer, keeping exactly ONE `textContent` write site. Rule: a live region must be observable BEFORE it is written — separate creation from announcement, and when the same call must do both, put a tick between them. **Submit LIFECYCLE exposure (#A11Y4):** the accessibility signals for an in-flight submit hang off the request window inside `send()` — the only place reached once an XHR genuinely exists — and that placement is the fix for a timing trap, not an accident: the loading state (`data-gina-loading`) is armed at CLICK time, BEFORE validation runs, so a signal hung there would arm-then-disarm on every rejected submit and announce a spurious busy/not-busy pair. Three effects, all scoped to that window. (1) The trigger's focus is captured before gina natively disables it and restored once the request settles: a natively `disabled` control cannot hold focus, so the browser drops focus to `<body>` and re-enabling does NOT bring it back (both measured), which silently cost every keyboard submit its place. The restore is deliberately conservative — only when focus is still on `<body>` and the trigger is still in the document — so a response that opened a popin, redirected, or focused the first invalid field keeps its own focus decision; gina restores what gina took, never more. The `<a>` branch is excluded from the capture: an anchor gets `aria-disabled` from the in-flight lock (since #B312 the framework's only `aria-disabled` write), which does not blur (measured control). (2) The trigger carries `aria-busy` for the request's duration — on the TRIGGER, never the form, because the live region is a CHILD of the form and an ancestor marked busy MAY be treated as "defer announcements in this subtree", which would silence the very channel used to announce; ARIA 1.2 defines no normative behaviour here, so that is a cheap hedge, and the same reading is why `aria-busy` can never substitute for an announcement (it produces none of its own). ⚠️ **Do NOT restate this as "assistive tech commonly defers"** — that wording shipped once and was WRONG: measured 2026-08-05 on VoiceOver/Chrome, an announcement made from inside an `aria-busy="true"` ancestor was spoken normally, so at least that pairing does not defer. The placement costs nothing and still guards ATs that might, but it is a precaution, never a claim about implementations. (3) The start is announced ONCE through the #A11Y2 region; completion announces NOTHING by design, because an errored response is already announced field-by-field by `handleErrorsDisplay` and a second status write over the same polite region in the same beat can truncate it. Announced strings are the framework's own, so they resolve through `gina.config.a11y` with English defaults (e.g. `{ submitting: 'Envoi…' }`) — deliberately separate from `setErrorLabels`, which is keyed by RULE name and owns rule messages. Rule: put a state signal where the state actually begins; a signal armed on intent rather than on the operation announces work that may never happen. **Error-association integrity (#A11Y5):** three fixes sharing one theme — an ARIA assertion is only worth what its target is worth. (a) Hiding the message div used gina's `.hidden` helper (`display: none !important`), which removes it from the accessibility tree entirely — fine for a soft warning, wrong at the TWO hide paths that coexist with an asserted `aria-invalid` (`refreshWarning`'s focus-driven hide, and the refresh re-create), where the field was announced invalid while `aria-errormessage` pointed at an unreachable target. Both now CLIP instead: inline declarations that hide visually but keep the node in the tree, with `display` carrying `!important` because that is what outranks the class's own `!important` (measured — a plain inline `display:block` loses to it); the class is deliberately left in place so consumer CSS keyed on `.hidden` keeps matching. (b) A polite region is announced on CHANGE, so re-writing byte-identical text is commonly not spoken — which is precisely the repeat-error case (blur a field that still fails the same rule). A trailing no-break space now makes the content differ without altering what is read out, and it self-cancels on the next write. NOTE this half is source-derived: the string demonstrably changes, but no assistive-technology pass has confirmed the re-announcement. ⚠️ The same caveat NO LONGER applies to #A11Y2's first-announcement fix — **V1 was CONFIRMED 2026-08-05 on VoiceOver/Chrome** by an A/B whose two arms differ only by a `setTimeout(0)`: the same-tick create-and-write was NOT spoken, the deferred write WAS. So the deferral is load-bearing in practice, not merely defensible in theory; the region-lifecycle discipline above is measured, not inferred. (c) `focusFirstInvalidField` (and its deliberately-duplicated inline twin) gated focusability on `typeof $field.focus == 'function'`, which is TRUE for every HTMLElement — a custom element with neither `tabindex` nor `delegatesFocus` passes it and its `focus()` is a silent no-op (measured), so a failed submit whose first invalid control was such a host focused nothing AND stopped searching. Both loops now confirm `document.activeElement` actually moved before stopping, which is also what makes the JSDoc's "skips unfocusable controls" claim true rather than aspirational. Rule: a capability probe (`typeof x.focus == 'function'`, `'foo' in el`) tests the API's PRESENCE, never its EFFECT — when the effect is what matters, assert the resulting state. Error-MESSAGE visibility has THREE write paths (create / refreshWarning's un-hide / the refresh re-create) and the re-create runs LAST in the live-check pass, so it owns the steady state: it is focus-aware — message hidden while the edited field is the active element, revealed on blur (soft warning border while typing). Rule: when an element is written by multiple paths in a single validation pass, guard the LAST writer — an earlier-writer fix is silently overridden. #B319 (0.6.5-alpha.2): the focus-driven hide is EXEMPT during the framework's own ANSWER focus — both refused-submit paths (the #B246/#B308 display-only reveal via `focusFirstInvalidField`, and the `validate.<id>` failure branch's inline focus twin) render errors then focus the first invalid field, and that focus's synchronous `focusin` re-entered the live-check listener and hid the just-rendered message: a refused submit explained itself only to a screen reader (the #A11Y5 clip kept the node resolvable) while sighted users saw nothing — despite the render itself being VISIBLE (no fieldName ⇒ the live-check branch is skipped, and activeElement is still the trigger/BODY at render time), which is also why an async `query` rule in a repro is incidental (the suppressing dispatch is synchronous inside `focus()`). Fix: a one-shot module flag raised around BOTH focus loops (try/finally, cleared on every exit) gates the focusin arm's `refreshWarning` call; the message stays visible with the hard `form-item-error` styling, aria state untouched, and the first later keystroke re-engages the mid-typing suppression unchanged (in that answered-then-typed configuration the border stays `form-item-error` — the answer focus re-registered the field so `lastFocused` reads it twice and the isWarning heuristic keeps the hard border while the active-element ternary hides the message; the hidden message is the contract, the border there is heuristic). Behavioral lock: `test/e2e/validator-submit-answer-visibility.spec.js` (4 arms, red-first against the pre-fix bundle); browserless pins: `test/core/validator-answer-focus.test.js`. The not-ready submit trigger is marked `data-gina-form-submit-gated="true"` + the class `gina-form-submit-disabled` (#B312 retired `aria-disabled` from this marker — its contract says not-operable while the #B246 gate deliberately answers the click with the error reveal; authored `aria-disabled` remains enforced by the gates and is never auto-cleared), NEVER native `disabled` (#B76 — a natively-disabled button emits no click, so the validate-render-focus guard could never run); `isValid()` is the real send gate, and **the framework now ships a default not-ready look (cursor `not-allowed` + dim; deliberately no `pointer-events`, which would swallow the click the reveal answers) that consumer CSS overrides**. Form-associated custom elements (FACEs) participate in binding + live-check (#CC2 — hyphenated members of `form.elements`; their own `.value` accessor is honoured, live-check rides the composed bubbling `change`; author contract: `static formAssociated`, a `name` attribute, a `.value` getter, composed `change` on commit). **Radio-group collection (#B221):** an unchecked non-boolean radio group whose rule declares a truthy `isRequired` is collected as an EMPTY value by BOTH collectors (`getFormValidationInfos` + the native-submit inline copy) so `isRequired` adjudicates it via the standard emptiness test — pre-fix no collection arm admitted the shape (each required `.checked`, a `true|false`-shaped value, or an `isBoolean` rule), the DOM handle was held in `$fields` but the VALUE never entered `fields`, so no rule ran against the group and a radio-group-only form short-circuited BOTH submit guards (field count 0 reads as nothing-to-validate → synthetic `isValid() === true`) and submitted its XHR with zero client-side validation. Other unchecked groups stay absent-when-unchecked (native parity: no rule / `isRequired: false` unchanged; `isBoolean`-declared groups keep the force-false arm), checked members post exactly as before, and on the auto path a required-empty form is invalid and never sends — so the wire only changes for the newly-gated shape. Enforcement-tightening: forms that silently submitted with nothing picked now gate on the pick (trigger marked not-ready at bind under default-on live-check — `data-gina-form-submit-gated` + class since #B312 — message on submit attempt, re-enabled after picking). **The re-enable is real only since #B228:** the radio live-check listener was registered under a `changed.<id>` event name nothing dispatches on a user pick — the form-level click proxy short-circuits into the radio state updater (which never dispatches any gina event), and the change proxy dispatches ONLY names present in the event registry, which radios never registered (checkboxes have that registration via their state-updater relay; radios' equivalent relay is keyed on the bare element id, which nothing triggers) — so the whole-form silent pass never re-ran after a pick and the trigger kept its bind-time disabled state indefinitely, while submit-time validation (a separate call chain) accepted the checked group and let the click-guard send: flows completed, only the trigger state was wrong (announced disabled to assistive tech; automation actionability checks refuse `aria-disabled`). Radios now ALSO register the proxy-dispatched `change.<id>` name alongside `changed.<id>` — the handler's radio arm accepted `change.`-typed events all along, so one registration line closes the loop: mouse, label and keyboard picks all re-run the field + whole-form passes (single delivery per pick — native `change` fires only on real state changes; the legacy `changed.<id>` name stays registered for the relay/programmatic path; checkboxes byte-identical). Latent since the live-check's introduction, invisible until #B221 armed it. **A field that DRIVES its own conditional block lost its BASE rules until #B229:** `forEachField`'s per-field tail read `if (isInCase || caseName == field) continue;`, and `caseName` is assigned inside the `_case_` scan loop that re-runs in full on EVERY field iteration, so it always held the LAST scanned `_case_` key's driver name — when the iterated field WAS that driver the `continue` skipped the rest of the iteration, base-rule check included. A rule shape `{ "group": { "isRequired": true }, "_case_group": { "conditions": [...] } }` therefore never adjudicated `group`'s own `isRequired` on the bind pass, the live-check global pass OR the submit pass: the form never gated and an empty submit went out with zero client-side validation — the silent-submit class above, resurfacing for the self-driving shape and structurally DOWNSTREAM of the collection fix (the group IS collected as `''`; only adjudication was missing). The tail is now split: `isInCase` keeps its own `continue` (it is dead code — never assigned truthy — and is preserved as such), and the `caseName == field` arm runs the base-rule check before continuing, restoring the driver's collected value around the call (the check deletes the field from the object it is handed, and that object is where the scan block re-reads the case VALUE on every later field iteration; a deleted entry re-seeds from the DOM, which for a radio group is the FIRST member's value regardless of `.checked`). Which conditions apply is unchanged — the direct-case block is never entered for a self-driving case, pre- or post-fix (measured) — and the fix is order-independent: a driver declared BEFORE another `_case_` block was already adjudicated (the tail's comparison never matched it), so the post-fix union is every driver carrying base rules. Client-only: the server form-body path throws earlier on any `_case_`-bearing rule set (conditional rules are unsupported there). Enforcement-tightening: a form built on this shape starts gating where it silently submitted. Known interplay, pre-existing: on a rule set with NO `$` tokens, a pick whose value matches a `_case_` condition lets the case machinery PERSISTENTLY replace injected fields' rules in the live store (the site-B replacement), so a later `reBind()` can re-arm the gate from the mutated store — `$`-bearing rule sets are immune (the dynamised-rules path clones). **A conditional driver's collected VALUE survives every full-form pass since #B230:** the base-rule check deletes each adjudicated field from the object it is handed, and until #B230 only the last-declared driver's entry was restored (the #B229 arm above) — any OTHER field that both carries base rules and drives a `_case_` lost its stored case value the moment its own rules were adjudicated, so later field iterations re-read it from the DOM (a radio group's FIRST member regardless of `.checked`) and matched conditions against a value the user never picked — spuriously requiring the wrong flow's fields (a correctly-completed picked flow could not submit), or with excluding condition rules under-validating the picked flow — while the driver's own direct-case block read `undefined` in the same pass and matched nothing. The entry is now backed up and restored around the base-rule check for any field driving a `_case_` in the live rules OR the pass-entry rule clone; the union matters because inside a direct-case recursion the pass's rule set is the condition's own rules, which carry no `_case_` keys, so the live-rules test alone is blind there. Non-driver fields keep the deletion untouched (the condition pull-in gate, the direct-case exclude injection and the async-`query` re-validation input all read those absences today), and a driver with no rules of its own is byte-identical — including the legitimate first-scan DOM seed for rule-less unchecked groups, which is preserved. **`setFlash` `[null, "message"]` works client-side since #B226 (the form the reference documents):** it previously lost its custom message in the browser ONLY — `lib/merge` classified a `null` array element as an object (`typeof null`) and dropped it on every no-override merge, and the client rules path re-merges the whispered rules (the `data-gina-form-rule` bind merge, the `gina.hasValidator` instance re-merge, the `_case_` merges), so the engine received a one-element array, bound the message to the ignored first `regex` argument, and rendered the built-in label; `["", "message"]` always survived (empty strings, `false` and `0` are primitives — `null` was the only casualty), and the server was unaffected (it reads the boot-loaded rules without those hops). The fix is in `lib/merge` itself, so no-override merges now preserve `null` array elements as VALUES framework-wide (and the index-merge branch stops manufacturing `{}` from a `null` source element) — a merge consumer relying on the silent compaction sees the `null` slots preserved. **Bracket-notation and nested-authored rule KEYS enforce on the SERVER form-body path since #B241:** the rule parser canonicalizes every rule key to a dotted path (`account[username]` becomes `account.username`; a nested rule tree flattens to its dotted leaves) while the server's fields map kept the RAW posted keys, so such rules never joined — the field was silently skipped with no warning, fail-open for every rule-keyed directive alike: checks (`isRequired`, `isEmail`, ...), the `exclude` drop, and value transforms — on BOTH production wire shapes (flat bracket keys: the client posts its name-keyed data as JSON and the JSON body path deliberately does no bracket expansion; and nested objects: the multipart and urlencoded parsers expand bracket names). The server now synthesizes dotted-canon field aliases ALONGSIDE the raw keys (originals kept, so `$name` cross-field tokens keep resolving off the raw posted names, and an all-flat payload synthesizes nothing — byte-identical behaviour), then folds alias outcomes back at egress: error keys return under the DOM-name bracket form the client renders against, and the validated data output keeps its materialized shape with exclusions and transforms applied (a parent object emptied by an exclusion is pruned along that alias's path only — a posted empty object survives). The client join was always bracket-on-both-sides (a named rule set passes through with its authored keys; nested-authored sets are reconstructed to bracket names at bind time), so this brings the server to parity — quirks included: a caller that posts the dotted key form keeps its own addressing, and the no-rules path still returns the payload verbatim. Behaviour change by design: a bracket-keyed or nested-authored rule that never fired before now enforces — anything relying on the old silent skip starts rejecting or dropping those fields. **Upload previews carry a text alternative (#A11Y7/U1, 0.6.4).** The staged-upload client layer builds its preview `<img>` in two MUTUALLY EXCLUSIVE branches of `onUpload` — one for a file with no server-side `preview` object, one for a returned preview variant — and neither set `alt` at all (not even `alt=""`), so assistive tech fell back to reading the temp URI aloud, once per staged file (WCAG 1.1.1). Both now set `alt` from `files[f].originalFilename`: the name the USER chose, deliberately NOT the sibling `files[f][key].originalFilename` of the preview variant, which is a server-generated artefact that means nothing to the person listening — the same distinction the adjacent `data-upload-original-filename` / `data-upload-preview-original-filename` pair already encodes. The fallback is `''` (a properly ignored image) rather than letting a missing name be spoken as a placeholder. The preview is INFORMATIVE, not decorative: it is the only signal telling a user which file is staged, and everything else in the upload layer is still silent — progress is attribute+`textContent` with no `role="progressbar"`/`aria-value*`, upload errors are an `innerHTML` write with no `role="alert"` that never calls the polite region the plugin already owns, and the reset control is an `<a href="#">` (U2/U3/U5, tracked in the accessibility audit, not fixed here). Maintainer gotcha: the two branches are per-file exclusive, so a one-site fix silently misses every upload whose server returns a preview object — fix both or neither. **A not-ready submit trigger really refuses the send — and its marker is aria-free (#B246 + #B312, 0.6.5).** `updateSubmitTriggerState()` marks an invalid form's trigger with `data-gina-form-submit-gated="true"` + the `gina-form-submit-disabled` class and deliberately never native-`disabled` (a natively-disabled button emits no click at all, so nothing could tell the user WHY it is dead) — and, since #B312, never `aria-disabled`: the gate ANSWERS the click with the error reveal, which that contract forbids for a control announced disabled, so the attribute belongs to consumers (authored marks the gates enforce and never auto-clear) and to the anchor in-flight lock. #B246's origin: NOTHING READ the marker — a click ran the entire submit cycle (collect → validate → `validate.<id>`) and only the `isValid()` gate stopped the send: the trigger was inert in appearance ONLY. `clickProxyHandler` now intercepts a disabled-or-gated trigger BEFORE the `submit.<id>` dispatch — so `bindSubmitEl`'s handler never runs, `isSubmitting` is never latched, and no send path is reachable by construction — and answers the click with a display-only `revealValidationState()` pass that renders every invalid field, focuses the first, and re-syncs the trigger state so a STALE not-ready marker on a form that has since become valid heals itself — the heal touches only the marker + class; authored `aria-disabled` and the in-flight lock survive it (#B313 closed by construction). The predicate `isTriggerDisabled()` reads the CLICKED element rather than `$formInstance.submitTrigger` (a form may carry several submit buttons while only one registers) and mirrors the popin plugin's existing trigger gate on the shared channels (authored `aria-disabled` + native `disabled`), so both subsystems agree there; since #B312 it ALSO fires on the validator-owned not-ready marker `data-gina-form-submit-gated="true"`. **#B293 (0.6.5) NARROWED that predicate: the native `disabled` ATTRIBUTE now counts only where `disabled` is not a real IDL property (`!('disabled' in $el)`).** As first shipped it accepted native `disabled` on ANY element, which silently killed the near-universal double-submit guard: a click listener on the submit button that sets `disabled` to block a second submit is bound to the button and therefore runs BEFORE gina's delegated form-level proxy reaches the gate, so the gate saw the attribute, cancelled the click, and `send()` never ran — then the consumer's own handler cleared the attribute again, leaving NOTHING marked, a normal-looking button, and every subsequent click equally dead. Measured: a natively-disabled `<button>` delivers **no click to JS at all** (the browser suppresses it), so on a real form control that arm could only ever fire on an attribute set DURING the dispatch — it protected nothing and cost everything; on an `<a>` or a custom element the browser enforces nothing, so there the attribute is still the only honest signal and is still read (`'disabled' in $el` measured: button/input `true`, anchor/custom-element/span `false`). Regression cover is an **e2e** (`test/e2e/validator-submit-native-disabled.spec.js`, 4 arms), deliberately NOT a replica: the defect is an ordering interaction between a consumer's own listener and gina's delegated proxy, which only the real bundle handling a real click can exhibit — `test/core/validator-submit-trigger-state.test.js` covers the same gate with replicas and stayed GREEN throughout, including two tests named as controls for the working case. Gotcha for maintainers: `focusFirstInvalidField()` deliberately DUPLICATES the inline focus loop inside the `validate.<id>` guard — that inline shape (`_a11yErrs` / `_aField`) is locked by source pins, so collapsing the two would break tests unrelated to this fix; keep them in step by hand if the focus rule ever changes. **Staged uploads speak (#A11Y7/U2+U3+U5, 0.6.4).** Three defects in one client layer, fixed together because they share one plumbing change. (a) `updateUploadProgressIndicator` wrote `textContent` plus two `data-gina-upload-progress*` attributes and NO ARIA, so any indicator that is not a native `<progress>` was unlabelled text; it now carries `role="progressbar"` + `aria-valuemin="0"` / `aria-valuemax="100"` / `aria-valuenow`, with `aria-valuenow` tracking the percent attribute EXACTLY — absent while `preparing`/`indeterminate` and on `error`, because an absent value is how a progressbar signals "unknown" while a stale or zeroed one reads as real, stalled progress. `reset` strips the ARIA too (it strips everything this layer ever set); `processing` advances the state only, preserving the last value so a determinate bar stays full through the server-side window. A native `<progress>` is deliberately untouched — it already exposes all of this, and an explicit role risks overriding the implicit one. (b) The TRANSITIONS are announced through the polite region `bindForm` already stands up: `uploadStarted` at the selection kickoff — deliberately NOT gated on the indicator being present, since opt-in-by-presence is right for a visual and wrong for an announcement — and `uploadComplete` in the success branch only. Per-tick progress is NEVER announced: `aria-live="polite"` coalesces but does not throttle, so one announcement per `onprogress` event would bury every other message on the page. There is deliberately NO generic `uploadError` key, because the error path announces the server's own message and a generic second write in the same beat would clobber it (the region is latest-wins — the same reasoning that keeps `releaseSubmitA11y` silent). (c) The upload error container is written via `innerHTML` while `display:none` and revealed only by a fade-in — exactly where a `role="alert"` on it is unreliable — so the error routes through that same region, announcing `$error.textContent` (the RENDERED text: a message can carry markup, and the url-action checker substitutes `<br>`). Note the container being consumer-owned is NOT the discriminator: the progress indicator is equally consumer-supplied and gina already writes attributes into both; the hidden-then-revealed shape is what rules out `alert`. (d) The generated reset control was `<a href="#">Reset</a>` — announced as a link, not activatable with Space, and identical across every staged file. It now carries `role="button"` plus an `aria-label` of `<visible label> <filename>`, the visible label kept as a PREFIX so the accessible name still contains it (WCAG 2.5.3 Label in Name) and reusing the consumer's own `data-gina-form-upload-reset-label` so it is translated wherever that already is. The role is stamped ONLY on an anchor (strict `/^a$/i`, unlike the loose `/a/i` guarding `href` a few lines above, which also matches TEXTAREA/CANVAS/LABEL), so a consumer supplying their own `<button>` keeps its implicit role. A `role="button"` that cannot be operated by Space would be worse than no role at all, so the binder adds a `keydown` handler mapping Space to the removal — anchors only, since a native button already fires click on Space and would otherwise run the removal twice. (e) That control is REMOVED from the DOM while holding focus (`$resetLink.remove()`, which its own comment requires be last so the click listener dies with the node). The finding described this as a `display:none`; both happen, and the removal is the decisive one — a fix aimed only at the hide would not have worked. Focus now moves to the file input BEFORE the removal, guarded on the control actually holding focus (a programmatic reset must not steal focus from elsewhere) and CONFIRMED afterwards rather than assumed, since `typeof focus == 'function'` is true for every HTMLElement and `focus()` is a silent no-op on one that cannot take it. The removal is then announced (`fileRemoved`, default `'%s removed'`) using a FUNCTION replacer — never a string — because a file name may contain `$` and a string replacement would expand `$&`/`$1` patterns inside it. All three new strings live in `A11Y_LABELS`, so `gina.config.a11y` overrides them. **Maintainer gotcha worth the line:** `test/core/validator-upload-reset-delete.test.js`'s `fnSlice` takes a FIXED 12000-char window from its declaration anchor, and this fix grew `onUploadResetOrDelete` by ~2.6k chars — pushing the `#R8` strip and the callback dispatch outside it and reddening four pins whose code was present and correctly ordered the whole time. Widened to 16000 (the function ends ~14.2k past its anchor), but it is still a fixed window and will bite again; the durable fix is to brace-walk to the real function end, as the sibling `validator-upload-progress.test.js` already does. **Not verified against assistive technology** — source-derived plus a served-bytes check, like the rest of this arc. **#B295 (2026-08-06) — an async `query` rule left the form's validity state STALE once it settled.** `onasyncCompleted` derives its verdict from `d.getErrors(field)`, which `form-validator.js` scopes to a SINGLE field, so its `isFormValid` means "this field passed", never "the form is valid" — and the entire update block sits behind `if (!isFormValid && …)`. A form that had just become valid therefore received NO update: `updateSubmitTriggerState` was never called, so the bind-time not-ready mark (then `aria-disabled="true"`; the `data-gina-form-submit-gated` marker since #B312) survived on a valid form and `$forms[id].errors` kept listing the field. Cosmetic until #B246 made the click gate read that marker — then the first click after the query settled was silently eaten, and only the second went through (`revealValidationState`'s re-sync self-heals it). Fixed by handing the verdict to the fresh whole-form pass the same function ALREADY runs for the not-last-field case (`needsGlobalReValidation`), whose unconditional `updateSubmitTriggerState($currentForm, gResult.isValid())` settles the marker while its `handleErrorsDisplay` empty-errors branch clears the stale record; measured to add NO extra round-trip, because the query rule's already-registered-listener guard short-circuits the re-run. **The measurement that CHOSE the fix, and the reason the two cheaper shapes are wrong:** with a second required field left invalid, that fresh pass reports its error while BOTH the field-scoped `cb._errors` AND the in-pass `d.getErrors()` report none — so un-gating the existing call, or letting `handleErrorsDisplay`'s empty-errors branch auto-enable via its own `updateSubmitTriggerState(..., true)`, would each have ENABLED submit on an invalid form and re-opened #B246's hole. Both were rejected on that arm, not on reading. Covered by `test/e2e/validator-async-query-revalidation.spec.js` (4 arms, red-first: 01+02 RED on the pre-fix dist, 03 "another field invalid ⇒ still blocked" and 04 "no query rule ⇒ sends" GREEN throughout); arm 01 deliberately waits on the QUERY settling rather than polling the trigger's not-ready marker, because polling the guard is exactly what let the pre-existing FACE e2e stay green through #B293. Browser-bundled ⇒ consumer pickup is restart AND re-bake. **#B294 (2026-08-06) — a submit trigger whose DOM NODE is REPLACED after binding (the AJAX / popin re-render shape) was PERMANENTLY and SILENTLY dead.** Submit binding is **two-stage and only the first stage is delegated**: the native click proxy is registered on the FORM (`:8090`, "Form-level proxies: capture bubbled events from in-tree controls") and survives any re-render, but the listener that does the work is attached to the trigger NODE by `bindSubmitEl` (`:8231`). `gina.events` is a **name → id-STRING** registry (`utils/events.js:42`), so the `submit.<id>` key outlives the node: after a `cloneNode(true)`+`replaceChild` the `:8037` dispatch gate still passes, `cancelEvent` suppresses the native submit, and `triggerEvent` fires at a node with no listener — so the form does not even fall back to a normal submit. Nothing self-heals (`bindForm` latches `binded` at `:8580` behind `:585`, cleared only by `unbindForm`; there is no `MutationObserver` and no `isConnected` check in the bind/dispatch path). **Pre-existing, NOT a 0.6.4 regression** — measured identically on published 0.6.3. Fixed by marking the bound node with a **JS expando** (`__ginaSubmitBoundFor`) and re-binding the live node at the dispatch gate when the marker is absent — measured: `cloneNode(true)` copies `data-*` attributes but NOT expandos, which is exactly why the inherited `dataset.ginaFormSubmitTriggerFor` is useless as a fresh-node test and was a red herring in the original filing. **The rejected alternative is worth recording:** delegating the custom `submit.<id>` event to the form also fixes it (measured), but was measured to RETAIN the `gina.events` key past `unbind` (`registryKeyDeleted:false` vs stock `true`), which would force an edit inside `unbindForm`'s removal loop — six guard variants all keyed on `gina.events[name] == element.id`. Covered by `test/e2e/validator-submit-trigger-rebind.spec.js` (4 arms, red-first: 01 single replacement and 03 double replacement RED pre-fix; 02 untouched-sends and 04 replaced-but-invalid-still-refuses GREEN throughout, so re-binding provably does not bypass validation). **Diagnostic reflex for any "it stopped working after we re-rendered" report: the question is not whether the click listener survived — it did, it is on the form — but whether the node carrying the stage-2 listener is still the node in the document.** Browser-bundled ⇒ restart AND re-bake. **The submit proxy's gesture gate reads the LIVE trigger (#B308, 0.6.5).** The per-form `submit` proxy enforced its disabled gate on a DOMParser copy of the form's innerHTML, testing only native `.disabled` on the copy — blind to the gate's own marker (then `aria-disabled`, `data-gina-form-submit-gated` since #B312, written by `updateSubmitTriggerState`) while SIGHTED on a native `disabled` written mid-dispatch by a double-submit guard (the exact attribute #B293 ruled must not count on a real control — so wrapped-label + double-submit guard was a dead submit, the #B293 signature alive on the submit path). A gated trigger's trusted gesture therefore ran the full collect → validate cycle and SENT whenever the values validated. Now an `e.isTrusted` submit on a form whose REGISTERED trigger is `isTriggerDisabled()` is cancelled and answered with the same display-only `revealValidationState()` as the #B246 click path (errors rendered, first invalid field focused, stale marker re-synced); programmatic `$forms[id].submit()` arrives untrusted (triggerEvent CustomEvent) and deliberately keeps the fresh-validate path — a programmatic submit is not operating the control, the fresh pass is the honest gate there, and cancelling on a cached marker would break fill-then-submit flows. Two gesture shapes reach this gate (probe-measured, Chromium): a wrapped-label click (`<button type="submit"><span>` — the span has no `.type`, so the click proxy never fires and the gesture surfaces as a native submit event), and Enter in a form with NO native submit button (e.g. an `<a data-gina-form-submit="true">` trigger — implicit submission fires a DIRECT trusted submit; with a native button present, Enter instead synthesizes a click on the default button that the #B246 CLICK guard already refuses while the marker stands, so that shape never needed this gate). Covered by `test/e2e/validator-submit-proxy-disabled-gate.spec.js` (5 arms, red-first BOTH directions via the committed `B308_PREFIX_BUNDLE` route-swap hook: label-click and A-tag-Enter RED pre-fix on an EVENT discriminator — the pre-fix cycle fires `validate.<id>` from the no-`on('submit')` branch, while the post-fix reveal is display-only and fires nothing; POSTs cannot discriminate, since an invalid form never sends on either side). Scene lesson for e2e authors: stale-marker scenes are racy BY CONSTRUCTION — a page-level `.value =` write on a bound field is heard SYNCHRONOUSLY (the value property is instrumented and the silent live validation repairs the marker inside the write), and a delayed global silent pass can heal a protocol-level fill's stale marker mid-arm — so pin gate behaviour on a genuinely-invalid form and discriminate on event emission. Browser-bundled ⇒ restart AND re-bake. **The anchor in-flight lock owns `aria-disabled` alone; the settle releases remove it unconditionally (#B309 → #B312, 0.6.5).** On an `<a data-gina-form-submit="true">` trigger send()'s in-flight lock and the not-ready gate historically SHARED `aria-disabled` with opposite lifecycles — the settle releases (the `loadend` fail-safe + the readyState-4 twin) removed it unconditionally, so a mid-flight gate re-mark was silently erased at settle (#B309), and the reveal's valid-form heal erased the LOCK mid-flight the same way (#B313). #B312's single-writer split dissolves the class structurally: the gate writes only `data-gina-form-submit-gated` + the class, the lock is the sole framework `aria-disabled` writer on anchors (buttons lock via native `disabled`), both settle releases are back to the plain unconditional `removeAttribute` (the interim #B309 verdict-stamp replay retired unreleased, its 18-test file with it), and the reveal's show branch never touches aria — so authored `aria-disabled` survives every framework pass (enforced by the gates, never auto-cleared: consumers relying on the old auto-clear-on-valid must migrate) and #B313 is closed by construction. Scene fact that shaped the design: live-check is DELIBERATELY quiet during a real in-flight submit (the #B192 valid branch holds the submit latch until settle), so keystrokes cannot re-mark mid-flight — the reachable mid-flight writer was the #B246/#B308 reveal itself. Covered by `test/core/validator-submit-gated-marker.test.js` (17 — block-scoped single-writer negatives immune to right-extension, authored-aria + lock survival behaviour, the shipped `isTriggerDisabled` EXTRACTED and EXECUTED, terminator-anchored release-shape pins, dist-fidelity pins validated red-first across the rebuild). Browser-bundled ⇒ restart AND re-bake. **isRequired's emptiness is anchored at both ends, and trim strips both sides (#B245, 0.6.5).** The emptiness conjunct was `!/^\s+/.test(value)` — leading-anchor only — so ANY leading-whitespace-padded value read as empty (" x" rejected, "x " accepted), and under the documented isRequired-first ordering a paired `trim` healed `local.data` AFTER the error was recorded: a rejected field carried the trimmed non-empty value in the same result. Now `/^\s+$/`: whitespace-ONLY is empty, padded values pass, all-whitespace and the empty string still fail. Same fix: `trim`'s replace gained its missing `g` flag (first-match-only rewriting left the trailing run whenever a leading run matched — " x " → "x "). The validation-rules reference is corrected (it had documented the leading-space rejection as intended while prescribing the isRequired-then-trim pairing that could not work under it). Covered by `test/core/validator-required-whitespace-trim.test.js` (block-scoped comment-stripped source pins + the heal matrix on the REAL plugin over the server auto path + dist-fidelity counts red-first across the rebuild). Browser-bundled ⇒ restart AND re-bake. #B345 (issue #59, unreleased): the #SCS1e paren/`return` strip used to run BEFORE the regex-vs-comparison branch split, so a parenthesized `is` regex literal compiled from MANGLED text — groups destroyed and anchors rebound (`/^(a|b)$/` behaved as `/^a|b$/`: substring-permissive for middle alternatives), quantified groups requantified (`(#TAG)?` → literal `#TA` + optional `G`), and a literal `return` inside a pattern deleted — all silent, since the stripped text still compiled as a valid regex (the `Invalid regex literal` throw structurally cannot catch it). The strip now lives inside the binary-comparison branch only (grammar lock + authored-paren tolerance unchanged — `("a") === ("a")` still strips to a valid comparison); a regex literal compiles exactly as authored, that branch never evaluating the condition as JS. Behavior change (changelog ACTION REQUIRED): paren-carrying `is` patterns now match as authored, i.e. stricter; and the paren-wrapped-regex edge `(/foo/)` now fails closed in the comparison branch instead of being unwrapped into the regex branch. Locked by `test/lib/validator-is-regex-parens.test.js` (14 — red-first source ORDER pins, the reported reproduction, anchors/quantifier/`return` arms, comparison-branch controls, gina.js + wrap-immune gina.min.js order pins).
896
896
 
897
- 210. **Multipart upload config is ENFORCED — groups, destination, limits, text-field capture + caps, terminal states (consolidates former #187/#188/#228/#230's server half; #B49/#B50/#B51/#B92-adjacent/#B93/#B97).** Every uploaded file must map to a CONFIGURED upload group: the resolved group (no/empty → the default `untagged`) must exist in `settings.json upload.groups` or the request is rejected 400 BEFORE the temp file is created, and `untagged` obeys its own config like any named group (pre-#B50 the checks ran only for defined non-`untagged` groups — an allow-list bypass; the shipped `untagged` default is `allowedExtensions: '*'` + `isMultipleAllowed: true`, so configure `untagged` restrictively or a client can route around a named group's allow-list). Destination + limits honoured: files stream to `uploadDir || tmpPath || os.tmpdir()` with a per-group `path` override and mkdir-if-missing before `createWriteStream` (the mkdir itself crash-guarded → 500, #B145); `maxFields` caps the per-request file COUNT (400 past the cap; 0/unset disables); `maxFieldsSize` parses its unit suffix (B/KB/MB/GB; bare number = MB) as the whole-body cap (431). **Multipart TEXT fields are captured** — a real busboy `'field'` listener (busboy silently SKIPS all non-file parts when none is registered) exposes them on `req.body` + `req[method]` (POST/PUT/PATCH only), values VERBATIM (no url-decode, no `"true"/"false"/"on"/"null"` coercion — the JSON body contract, deliberately: otherwise the same client `send(fd)` call would change value TYPES with file presence), bracket-notation names nested through the urlencoded path's own layer, duplicate plain names last-wins; caps `upload.maxTextFields` (default 1000) + `upload.maxTextFieldSize` (default 1MB, unit-aware, explicit 0 = no limit) answer 400 on breach instead of busboy's silent skip/truncation. **A multipart request with no successfully-parsed file part reaches a TERMINAL state:** fields-only → the request resumes; malformed/empty → 400 via `busboy.on('error')` with a double-response guard (busboy terminates in exactly `finish` XOR `error`; pre-#B93/#B97 both were unauthenticated pre-routing DoS holes — an eternal hang, and an uncaughtException → SIGTERM bundle kill). Rules: a per-group restriction is only a control if the UNCONFIGURED/default case is denied or constrained, never waved through; a documented config key is only real if a code path reads it — grep the consumer before assuming a setting works; any stream/parser whose SUCCESS path drives a request's continuation must ALSO handle its error/empty terminals. Since #STO1 slice 1 a group may also carry driver: '<name>', routing its self.store() step through settings.storage (entry #300; the parse/staging path above is unchanged). Server-side only. Tests: `test/core/upload-groups.test.js` + `upload-config.test.js` + `multipart-nonfile-terminal.test.js` + `multipart-field-capture.test.js` (+ the client `send(FormData)` half: `validator-send-formdata-multipart-fields.test.js`). **Server-path integrity + terminal-state hardening (former #263/#267/#268/#270 — #B103/#B142/#B143/#B144/#B145/#B223, 0.5.22-0.5.24):** a multipart body stays RAW end-to-end — `request.isMultipart` is computed ONCE at the request prologue and gates the request-stream `setEncoding` (a multipart stream reaches busboy as raw Buffers, its documented input; the decode remains for the text-body branches), write-pipeline chunks pass through VERBATIM, and `req.files[].size` counts BYTES, never a decoded string's length (#B103 — pure-ASCII surviving both old decode layers is why text uploads always worked while every real binary corrupted on every native multipart client: a PNG's 0x89 magic landed as 0xFD). The per-file `size` is finalized in the liner Transform's `_flush` (#B142 — the source-'end' record ran one pipe hop UPSTREAM of the byte counter with 16-object high-water marks still queued, under-counting ~25% on a 1.5MB file while md5 stayed perfect: the bytes were intact, only the number lied; `_flush` runs after the last `_transform` and strictly before the write stream can emit 'finish', both interleavings measured exact), and BOTH write-stream terminal listeners arm AT STREAM CREATION in the 'file' handler (#B143 — arming inside `busboy.on('finish')` lost the race for any early-finishing small file: Node never replays 'finish' for a late listener, so a throttled two-file upload hung deterministically, ~1 in 13 unthrottled; unauthenticated, both engines). Resume fires on `busboyDone && pending === 0`, whichever event lands last (the zero-pending branch still covers the fields-only #B93 terminal), and a mid-stream write error (missing dir, disk full) gets a guarded 500 instead of the historical unhandled-'error' uncaughtException SIGTERM. Downstream, `Controller.store()`'s mover streams each file to a temp sibling and publishes with an ATOMIC rename (a reader never observes a partial file; a pre-existing destination is replaced only on success), propagates the REAL filesystem Error (previously masked as `No file to upload`), and never settles the callback twice (#B223). The destination mkdir is crash-guarded (#B145): a group whose custom `path` has a read-only/EACCES/EROFS parent answers a guarded 500 naming group + path — a server CONFIG problem is 500 (the #B50 unconfigured-group 400 stays upstream and unchanged), and pre-fix `fs.mkdirSync` threw SYNCHRONOUSLY inside the parser callback → uncaughtException → SIGTERM, an unauthenticated single-request bundle kill (the multipart parse precedes routing and all middleware, both engines). A per-group `simulateWriteError: true` flag (#B144) lets a consumer deterministically fire the guarded-500 write-error path OUTSIDE production scope only (`!NODE_SCOPE_IS_PRODUCTION`): the 'file' handler creates the REAL write stream, arms the REAL terminal listeners, then synthetically `destroy()`s it — the exact terminal semantics of a real ENOSPC/EIO (an errored stream never emits 'finish', so the request stays terminal at the 500), N destroyed parts collapsing to exactly ONE 500 via throwError's `!res.headersSent` guard; a boot warn scans `upload.groups` for the flag in both scopes (production: "IGNORED — remove before shipping"; else "PROBE active"); nothing ships active. The `group="…"` tag rides a Content-Disposition PARAMETER that `curl -F` / browser FormData cannot emit (the `@rhinostone/busboy` fork parses it into `info.dispositionParams.group`), so a faithful probe HAND-BUILDS the multipart body. **The staged upload client layer (`data-gina-form-upload-*`, lives in the validator plugin — browser-bundled ⇒ consumers RE-BAKE at pickup; former #269/#271/#272/#274 — #R8 slices 1-2, #B146/#B147/#B148/#B149):** the staging POST body is assembled as a `Blob` with each File object embedded RAW (#B148 — the historical `FileReader`/`ab2str` DOMString concatenation UTF-8-inflated every byte >= 0x80 on the wire, ×1.49 measured, a PNG's 0x89 arriving as 0xC2 0x89; the corruption only STARTED at 0.5.22 because the pre-#B103 server's two since-removed decode layers EXACTLY reversed the inflation — two wrongs cancelling — so the #B103 server fix is what exposed the client defect). Multipart FRAMING is byte-identical (same boundary delimiters, same `name=`/`group=`/`filename=` disposition-parameter set, values percent-escaped for CR/LF/double-quote per RFC 7578 §5.1.1); a fixed client must pair with a server >= 0.5.22 (an older server's decode layers would corrupt the now-raw bytes — the #B103 corruption in reverse); files corrupted by the defect are LOSSLESSLY recoverable (the stored bytes are exactly the UTF-8 encoding of the originals: decode utf8 → re-encode latin1). **Upload progress (#R8 slice 1):** `send()` assigns `xhr.upload.onprogress` FRESH on every send (the module-scoped XHR was reused across sends — a stale handler replays the previous send's closure with the wrong id), dispatching the REGISTERED event `uploadProgress.<uploadFormId>` (the events-array registration is required — `on()` validates names against the plugin registry) with `{ status: 100, progress: <int 0-100 | null when lengthComputable is false>, loaded, total, lengthComputable, files: [names] }` — a per-REQUEST aggregate (ONE staging XHR carries every file of a selection; per-file wire progress is not separable). Consumer surfaces: `data-gina-form-upload-on-progress` (bare window identifier, the -on-success convention) + a declarative indicator `data-gina-form-upload-progress="<elId>"` defaulting to `<fieldId>-progress`, opt-in by element presence. The updater feature-detects the target: a native `<progress>` tracks value=loaded/max=total (indeterminate = the value attribute REMOVED for the native animation; error = value 0, NEVER indeterminate — that animation would read as still working); any other element gets percent textContent + `data-gina-upload-progress`/`data-gina-upload-progress-state` styling hooks (preparing|uploading|indeterminate|processing|complete|error) with NO hardcoded wording (i18n-neutral — label via CSS on the state attribute). Lifecycle: `preparing` at selection (covers the FileReader/assembly phase), `uploading` per frame, `processing` at `xhr.upload.onloadend` (the browser finished SENDING — advances the state attribute ONLY, leaving value/percent as the last frame left them so a determinate bar stays visually full), `complete`/`error` finalized in onUpload (one chokepoint covers success and every error/timeout path), reset/delete strips the indicator. The response-side download channel `progress.<id>` is byte-untouched (it also serves attachment downloads). **Drag-and-drop (#R8 slice 2):** a file input carrying `data-gina-form-upload-dropzone="<elementId>"` gets drag listeners bound on the named element at form-bind time; dropped files are assigned to the input (`input.files = dataTransfer.files`) and the input's `change` is re-fired synthetically, so the ENTIRE staging pipeline — group tagging, virtual form, staging POST, previews, hidden metadata fields, reset/delete, upload progress — runs with zero duplicated logic (the change handler reads only `currentTarget`, so a synthetic dispatch is indistinguishable from a trusted one on this path). Contract: EXPLICIT-id-only (deliberately NO `<fieldId>-dropzone` default — auto-binding a coincidentally-named element would attach drag semantics to markup that may carry its own drop handling; absent attribute = inert, missing element = console.warn + inert); the zone is stamped `data-gina-upload-dropzone` (value = owner input id — the first-wins guard: one zone serves one input) and `data-gina-upload-dropzone-state` (`idle` → `over` on a file-drag hover, DEPTH-COUNTED so child-boundary crossings never flicker → `dropped` → back to `idle` at the same onUpload chokepoint that finalizes progress, and at reset/delete) — pure CSS hooks, no hardcoded wording. Only FILE drags react (`dataTransfer.types` must carry `Files`; text/link drags fall through untouched, never preventDefault'd); a multi-file drop on a non-`multiple` input keeps the FIRST file only (console.warn) — configured groups still enforce `isMultipleAllowed` server-side; a bare file input already accepts native browser drops through the same change handler — the attribute exists to delegate a larger/styled element. **Action/preview/bind fixes (#B146/#B147/#B149):** `checkUploadUrlActions`'s default-route fallback writes the attribute ACTUALLY being checked (`setAttribute(action, …)` — it used to hardcode the staging attribute and silently repoint a staging POST at the resolved reset/delete default route, compounding to a SILENT failure when `toUrl()`'s absolute origin tripped a CORS preflight → XHR status-0 → the commented-out status-0 branch); the preview-container guard uses the typeof-null pattern (`&& previewContainer` — `getElementById` returns element|null and `typeof null === 'object'`, so a preview-element MISS used to TypeError the success handler at `.id`); an upload-only input binds QUIETLY — `-delete-action` deliberately has no framework default (it removes an already-saved file, an app-specific endpoint), so a no-default absent action is a single `console.debug` + early return (a WITH-default action that genuinely fails to resolve still errors; the delete requirement stays enforced lazily at `onUploadResetOrDelete`). Tests: `upload-binary-integrity.test.js` / `upload-size-accuracy.test.js` (11) / `multipart-multifile-resume.test.js` (15) / `upload-write-error-probe.test.js` / `upload-config.test.js §07` / `validator-upload-progress.test.js` (46) / `validator-upload-binary-wire.test.js` / `validator-upload-action-guard.test.js` — all red-first-validated with control-gated extractions of the shipped bytes.
897
+ 210. **Multipart upload config is ENFORCED — groups, destination, limits, text-field capture + caps, terminal states (consolidates former #187/#188/#228/#230's server half; #B49/#B50/#B51/#B92-adjacent/#B93/#B97).** Every uploaded file must map to a CONFIGURED upload group: the resolved group (no/empty → the default `untagged`) must exist in `settings.json upload.groups` or the request is rejected 400 BEFORE the temp file is created, and `untagged` obeys its own config like any named group (pre-#B50 the checks ran only for defined non-`untagged` groups — an allow-list bypass; the shipped `untagged` default is `allowedExtensions: '*'` + `isMultipleAllowed: true`, so configure `untagged` restrictively or a client can route around a named group's allow-list). Destination + limits honoured: files stream to `uploadDir || tmpPath || os.tmpdir()` with a per-group `path` override and mkdir-if-missing before `createWriteStream` (the mkdir itself crash-guarded → 500, #B145); `maxFields` caps the per-request file COUNT (400 past the cap; 0/unset disables); `maxFieldsSize` parses its unit suffix (B/KB/MB/GB; bare number = MB) as the whole-body cap (431). **Multipart TEXT fields are captured** — a real busboy `'field'` listener (busboy silently SKIPS all non-file parts when none is registered) exposes them on `req.body` + `req[method]` (POST/PUT/PATCH only), values VERBATIM (no url-decode, no `"true"/"false"/"on"/"null"` coercion — the JSON body contract, deliberately: otherwise the same client `send(fd)` call would change value TYPES with file presence), bracket-notation names nested through the urlencoded path's own layer, duplicate plain names last-wins; caps `upload.maxTextFields` (default 1000) + `upload.maxTextFieldSize` (default 1MB, unit-aware, explicit 0 = no limit) answer 400 on breach instead of busboy's silent skip/truncation. **A multipart request with no successfully-parsed file part reaches a TERMINAL state:** fields-only → the request resumes; malformed/empty → 400 via `busboy.on('error')` with a double-response guard (busboy terminates in exactly `finish` XOR `error`; pre-#B93/#B97 both were unauthenticated pre-routing DoS holes — an eternal hang, and an uncaughtException → SIGTERM bundle kill). Rules: a per-group restriction is only a control if the UNCONFIGURED/default case is denied or constrained, never waved through; a documented config key is only real if a code path reads it — grep the consumer before assuming a setting works; any stream/parser whose SUCCESS path drives a request's continuation must ALSO handle its error/empty terminals. Since #STO1 slice 1 a group may also carry driver: '<name>', routing its self.store() step through settings.storage (entry #300; the parse/staging path above is unchanged). Server-side only. Tests: `test/core/upload-groups.test.js` + `upload-config.test.js` + `multipart-nonfile-terminal.test.js` + `multipart-field-capture.test.js` (+ the client `send(FormData)` half: `validator-send-formdata-multipart-fields.test.js`). **Server-path integrity + terminal-state hardening (former #263/#267/#268/#270 — #B103/#B142/#B143/#B144/#B145/#B223, 0.5.22-0.5.24):** a multipart body stays RAW end-to-end — `request.isMultipart` is computed ONCE at the request prologue and gates the request-stream `setEncoding` (a multipart stream reaches busboy as raw Buffers, its documented input; the decode remains for the text-body branches), write-pipeline chunks pass through VERBATIM, and `req.files[].size` counts BYTES, never a decoded string's length (#B103 — pure-ASCII surviving both old decode layers is why text uploads always worked while every real binary corrupted on every native multipart client: a PNG's 0x89 magic landed as 0xFD). The per-file `size` is finalized in the liner Transform's `_flush` (#B142 — the source-'end' record ran one pipe hop UPSTREAM of the byte counter with 16-object high-water marks still queued, under-counting ~25% on a 1.5MB file while md5 stayed perfect: the bytes were intact, only the number lied; `_flush` runs after the last `_transform` and strictly before the write stream can emit 'finish', both interleavings measured exact), and BOTH write-stream terminal listeners arm AT STREAM CREATION in the 'file' handler (#B143 — arming inside `busboy.on('finish')` lost the race for any early-finishing small file: Node never replays 'finish' for a late listener, so a throttled two-file upload hung deterministically, ~1 in 13 unthrottled; unauthenticated, both engines). Resume fires on `busboyDone && pending === 0`, whichever event lands last (the zero-pending branch still covers the fields-only #B93 terminal), and a mid-stream write error (missing dir, disk full) gets a guarded 500 instead of the historical unhandled-'error' uncaughtException SIGTERM. Downstream, `Controller.store()`'s mover streams each file to a temp sibling and publishes with an ATOMIC rename (a reader never observes a partial file; a pre-existing destination is replaced only on success), propagates the REAL filesystem Error (previously masked as `No file to upload`), and never settles the callback twice (#B223). Since #B227 the SAME semantics govern the general-purpose byte-writer in `helpers/path.js` behind `_().cp()` / `PathObject.mv()` (which CLI copy/build/rename paths ride): temp sibling in the destination's own directory + atomic rename (a pre-existing destination is no longer unlinked BEFORE the copy — its content survives a failed copy), a source-stream error listener (was an unhandled `error` event → process kill), a settled latch (a destination-side failure previously settled twice, the second time as a success, forking `browseCopy`'s directory recursion), and a real `Error` instead of the former plain string (`Error on Path.cp(...): Not found ...`) — so caller `err.stack` prints stop logging `undefined`. The destination mkdir is crash-guarded (#B145): a group whose custom `path` has a read-only/EACCES/EROFS parent answers a guarded 500 naming group + path — a server CONFIG problem is 500 (the #B50 unconfigured-group 400 stays upstream and unchanged), and pre-fix `fs.mkdirSync` threw SYNCHRONOUSLY inside the parser callback → uncaughtException → SIGTERM, an unauthenticated single-request bundle kill (the multipart parse precedes routing and all middleware, both engines). A per-group `simulateWriteError: true` flag (#B144) lets a consumer deterministically fire the guarded-500 write-error path OUTSIDE production scope only (`!NODE_SCOPE_IS_PRODUCTION`): the 'file' handler creates the REAL write stream, arms the REAL terminal listeners, then synthetically `destroy()`s it — the exact terminal semantics of a real ENOSPC/EIO (an errored stream never emits 'finish', so the request stays terminal at the 500), N destroyed parts collapsing to exactly ONE 500 via throwError's `!res.headersSent` guard; a boot warn scans `upload.groups` for the flag in both scopes (production: "IGNORED — remove before shipping"; else "PROBE active"); nothing ships active. The `group="…"` tag rides a Content-Disposition PARAMETER that `curl -F` / browser FormData cannot emit (the `@rhinostone/busboy` fork parses it into `info.dispositionParams.group`), so a faithful probe HAND-BUILDS the multipart body. **The staged upload client layer (`data-gina-form-upload-*`, lives in the validator plugin — browser-bundled ⇒ consumers RE-BAKE at pickup; former #269/#271/#272/#274 — #R8 slices 1-2, #B146/#B147/#B148/#B149):** the staging POST body is assembled as a `Blob` with each File object embedded RAW (#B148 — the historical `FileReader`/`ab2str` DOMString concatenation UTF-8-inflated every byte >= 0x80 on the wire, ×1.49 measured, a PNG's 0x89 arriving as 0xC2 0x89; the corruption only STARTED at 0.5.22 because the pre-#B103 server's two since-removed decode layers EXACTLY reversed the inflation — two wrongs cancelling — so the #B103 server fix is what exposed the client defect). Multipart FRAMING is byte-identical (same boundary delimiters, same `name=`/`group=`/`filename=` disposition-parameter set, values percent-escaped for CR/LF/double-quote per RFC 7578 §5.1.1); a fixed client must pair with a server >= 0.5.22 (an older server's decode layers would corrupt the now-raw bytes — the #B103 corruption in reverse); files corrupted by the defect are LOSSLESSLY recoverable (the stored bytes are exactly the UTF-8 encoding of the originals: decode utf8 → re-encode latin1). **Upload progress (#R8 slice 1):** `send()` assigns `xhr.upload.onprogress` FRESH on every send (the module-scoped XHR was reused across sends — a stale handler replays the previous send's closure with the wrong id), dispatching the REGISTERED event `uploadProgress.<uploadFormId>` (the events-array registration is required — `on()` validates names against the plugin registry) with `{ status: 100, progress: <int 0-100 | null when lengthComputable is false>, loaded, total, lengthComputable, files: [names] }` — a per-REQUEST aggregate (ONE staging XHR carries every file of a selection; per-file wire progress is not separable). Consumer surfaces: `data-gina-form-upload-on-progress` (bare window identifier, the -on-success convention) + a declarative indicator `data-gina-form-upload-progress="<elId>"` defaulting to `<fieldId>-progress`, opt-in by element presence. The updater feature-detects the target: a native `<progress>` tracks value=loaded/max=total (indeterminate = the value attribute REMOVED for the native animation; error = value 0, NEVER indeterminate — that animation would read as still working); any other element gets percent textContent + `data-gina-upload-progress`/`data-gina-upload-progress-state` styling hooks (preparing|uploading|indeterminate|processing|complete|error) with NO hardcoded wording (i18n-neutral — label via CSS on the state attribute). Lifecycle: `preparing` at selection (covers the FileReader/assembly phase), `uploading` per frame, `processing` at `xhr.upload.onloadend` (the browser finished SENDING — advances the state attribute ONLY, leaving value/percent as the last frame left them so a determinate bar stays visually full), `complete`/`error` finalized in onUpload (one chokepoint covers success and every error/timeout path), reset/delete strips the indicator. The response-side download channel `progress.<id>` is byte-untouched (it also serves attachment downloads). **Drag-and-drop (#R8 slice 2):** a file input carrying `data-gina-form-upload-dropzone="<elementId>"` gets drag listeners bound on the named element at form-bind time; dropped files are assigned to the input (`input.files = dataTransfer.files`) and the input's `change` is re-fired synthetically, so the ENTIRE staging pipeline — group tagging, virtual form, staging POST, previews, hidden metadata fields, reset/delete, upload progress — runs with zero duplicated logic (the change handler reads only `currentTarget`, so a synthetic dispatch is indistinguishable from a trusted one on this path). Contract: EXPLICIT-id-only (deliberately NO `<fieldId>-dropzone` default — auto-binding a coincidentally-named element would attach drag semantics to markup that may carry its own drop handling; absent attribute = inert, missing element = console.warn + inert); the zone is stamped `data-gina-upload-dropzone` (value = owner input id — the first-wins guard: one zone serves one input) and `data-gina-upload-dropzone-state` (`idle` → `over` on a file-drag hover, DEPTH-COUNTED so child-boundary crossings never flicker → `dropped` → back to `idle` at the same onUpload chokepoint that finalizes progress, and at reset/delete) — pure CSS hooks, no hardcoded wording. Only FILE drags react (`dataTransfer.types` must carry `Files`; text/link drags fall through untouched, never preventDefault'd); a multi-file drop on a non-`multiple` input keeps the FIRST file only (console.warn) — configured groups still enforce `isMultipleAllowed` server-side; a bare file input already accepts native browser drops through the same change handler — the attribute exists to delegate a larger/styled element. **Action/preview/bind fixes (#B146/#B147/#B149):** `checkUploadUrlActions`'s default-route fallback writes the attribute ACTUALLY being checked (`setAttribute(action, …)` — it used to hardcode the staging attribute and silently repoint a staging POST at the resolved reset/delete default route, compounding to a SILENT failure when `toUrl()`'s absolute origin tripped a CORS preflight → XHR status-0 → the commented-out status-0 branch); the preview-container guard uses the typeof-null pattern (`&& previewContainer` — `getElementById` returns element|null and `typeof null === 'object'`, so a preview-element MISS used to TypeError the success handler at `.id`); an upload-only input binds QUIETLY — `-delete-action` deliberately has no framework default (it removes an already-saved file, an app-specific endpoint), so a no-default absent action is a single `console.debug` + early return (a WITH-default action that genuinely fails to resolve still errors; the delete requirement stays enforced lazily at `onUploadResetOrDelete`). Tests: `upload-binary-integrity.test.js` / `upload-size-accuracy.test.js` (11) / `multipart-multifile-resume.test.js` (15) / `upload-write-error-probe.test.js` / `upload-config.test.js §07` / `validator-upload-progress.test.js` (46) / `validator-upload-binary-wire.test.js` / `validator-upload-action-guard.test.js` — all red-first-validated with control-gated extractions of the shipped bytes.
898
898
 
899
899
  211. **Request-body parsing contract — verbatim JSON, `req.rawBody`, and crash-safe decodes (consolidates former #162/#163/#164; #B28/#B30/#B64).** `application/json` bodies (POST/PUT/PATCH) parse VERBATIM first — `JSON.parse(request.body)`, with a `decodeURIComponent` fallback ONLY when that throws — so the client's exact types and string contents survive (no double-decode, no `"true"/"false"/"on"/"null"` coercion, no bracket-key expansion; urlencoded/absent content-types keep the legacy decode+coerce path; error disposition unchanged — POST/PATCH 500 only when both attempts fail, PUT warns). The framework's own server-to-server client (`self.query()`) sends raw `JSON.stringify(data)` for JSON bodies (RFC5987 value-encoding is for header values, not request bodies), and the inter-bundle HTTP/2 proxy no longer copies the INCOMING request's Content-Type onto the outbound JSON body it serialized itself — a urlencoded label re-routed raw JSON through the receiving parser's urlencoded branch and corrupted string values (the only incoming-CT→outbound copy framework-wide; kept for non-JSON bodies). `request.rawBody` snapshots the exact unparsed body (reference assignment, always-on, non-multipart only, `''` for empty) immediately BEFORE parsing mutates `request.body` — inbound-webhook HMAC verification needs the raw bytes, which middleware cannot recover after the stream drains; both engines share this single `core/server.js` pipeline. Malformed percent-escapes (`%`, `%zz`, truncated `%E0%A`) in a URL or query no longer kill the bundle (#B30 — an unauthenticated single-request DoS: any unguarded `decodeURIComponent` OR `decodeURI` on the request path threw `URIError` → uncaughtException → SIGTERM): redundant SECOND decodes on the GET/HEAD branches were DROPPED (the engine query parser + the data helper each already decode once, guarded), and every genuine first decode of attacker-controllable input routes through `safeDecodeURIComponent`/`safeDecodeURI` (try/decode/fallback-to-raw) — including the ERROR paths, where a malformed-% URL to a missing asset previously crashed FROM the error handler, turning a would-be 404 into a bundle kill. Sweep rule: `decodeURIComponent` and `decodeURI` are the same throwing family — sweep BOTH; fix the throw sites, never widen the uncaughtException net. Sibling #B64 (0.5.7): the two static-file resolvers confine the resolved filename to the matched mapping target (`path.resolve` both sides + separator-aware containment), so `../` / `%2F` / `%2e%2e` cannot escape to sibling files — an escape 404s exactly like a missing file. Second site, same guard (#B179, Security/CWE-22): the dev-mode Inspector SPA handler (`/_gina/inspector/*`) built its filename the same way — request target minus the prefix, joined onto the asset root, passed through the `_()` path helper — and `_()` calls `Path.normalize`, which **RESOLVES** `..` rather than rejecting it, so a literal `../` read any absolute path the bundle process could reach (verified live: 200 on `/etc/passwd`; encoded `%2e%2e` unaffected, since this handler never decodes — and decoding was deliberately not added). Node does not normalise the request-target (only clients do), so a raw HTTP client reaches it where a browser cannot. Unlike the static resolvers, this handler is **DUPLICATED per engine** (server.js + server.isaac.js) and `confineToBase` is scoped inside server.js's `Server()` closure — unreachable from isaac, which therefore declares an engine-local twin, pinned against drift by a test asserting both extracted copies agree. **Rule: any handler joining request-derived segments onto a base MUST confine the RESOLVED path; path normalisation is not a traversal defence, it is the mechanism that makes the escape land.** Corollary for endpoint families: check the SIBLING handlers' gating too — the Inspector endpoint carries no IP allowlist while its `/_gina/*` neighbours (`info`, `cache/stats`, `cache/clear`) all gate on `lib.admin.isClientAllowed`.
900
900
 
@@ -951,7 +951,7 @@ Dev-mode query instrumentation captures every database query tied to the current
951
951
 
952
952
  259. **Server-side error responses strip stack traces outside local scope — the engine-level throwError sanitizes centrally; the full stack goes to the server log (2026-07-20, 0.5.21).** Two throwError implementations exist: the controller-side one (scope-gated since 0.5.x — it strips the `stack` FIELD outside local scope) and the SERVER-side engine-attached one, whose feeders (router action/middleware catches, server internals) flatten `err.stack` INTO the message string — so non-local 500 bodies used to carry absolute framework paths + stack frames in the JSON `error` field, the inline HTML error fallback, and custom-error page data. The server-side throwError now detects a stack-bearing message (`/\n\s+at\s/`, the V8 frame format) outside local scope, logs the FULL text server-side, and ships only the message line; an object msg has `.stack` logged then stripped off a clone (the caller's object is untouched). Local scope is byte-identical — the dev toolbar reads the stack off the wire. The gate reads the same NODE_SCOPE_IS_LOCAL env var as the controller-side gate (set once at bootstrap), deliberately NOT the config-derived per-bundle scope flag: that flag derives from the project's DEFAULT scope and can lag a boot started with an explicit NODE_SCOPE/--scope. Siblings fixed with it: the JSON render delegate's HTTP/1.1 reason-phrase catch ships `err.message` (a multi-line reason phrase is invalid on the HTTP/1.1 status line — Node rejects the write), and the cached-file stream error path ships message-only. Consumers who relied on wire stacks for service-to-service debugging keep them on LOCAL scope only; everywhere else the server log carries the diagnostic.
953
953
 
954
- 260. **A bundle with a missing or unreadable config/routing.json REFUSES to boot (matching the malformed-JSON path), and the route-lookup not-found error names the bundle + its rule count (2026-07-20, 0.5.21).** Bundle config hydration is readdir-driven: a routing.json absent at boot readdir time was never iterated, so the bundle silently booted with only the framework-synthetic routes (bundle-status, custom-error-page, webroot + the upload endpoints) — every app route 404'd and a sibling bundle's cross-bundle route lookup threw hours later with a message indistinguishable from a mistyped rule; there is no prod re-hydration. The config loader now tracks whether the config-file loop resolved a REAL routing config (bundle dir or shared-config complement; env-versioned `routing.<env>.json` variants ride their base file's iteration, so an env-only file still refuses — it never loaded anyway) and otherwise fails the boot with an error naming the bundle + environment; an EXTERNAL supervisor restart (an orchestrator liveness/restart, a container restart policy such as restart=always, an init system) then retries until the release tree settles, so a mid-deploy race self-heals — gina's own daemon does NOT retry a startup crash: it reports `crashed during startup` once and stops, so a bare `gina bundle:start` deployment is fail-stop until a manual restart. BEHAVIOR CHANGE: a deployment that relied on the silent half-boot must fix its release artifact. The `RoutingHelper::getRouting` not-found throw keeps its historical prefix and appends `(bundle <name> holds N rules)`, so a degraded or near-empty table is tellable from a plain mistyped rule — browser bundle included (dist rebuilt). **SIBLING REFUSAL — the project `env.json` block (#B181(b), 2026-07-31, 0.6.2).** The same function refuses one step earlier when `envConf[bundle][env]` is absent: the bundle list is built inside the env.json template-merge loop, so an env.json that is ABSENT (`userConf` stays `false`), empty, or that declares only bundles the manifest does not list yields an EMPTY list — `bundles[b]` is then undefined, `bundle` falls back to `startingApp`, and `setServerCoreConf` dereferenced a block that was never populated, dying as an uncaughtException (`Cannot read properties of undefined (reading '<env>')`, exit 143) that named neither env.json nor the bundle. It now refuses through the same `callback(new Error())` path (exit 1), naming the bundle, the env, the resolved env.json path, and whether the file was found at all — so a MISSING file and a present-but-incomplete one give different remedies; `setServerCoreConf` also throws a named error for any direct caller. Measured on fresh isolated scenes: the scaffolded-env.json baseline still boots and serves, and all three degenerate shapes refuse cleanly — the predicate can only fire where the boot ALREADY crashed, so it cannot refuse a working configuration. **SIBLING — the missing `host` default (#B181(a), 2026-07-31, 0.6.2).** `host` was the ONLY key the per-bundle configuration reads that carried no framework-side default — absent from both the env and settings templates, and written solely by the CLI into the project's own env.json — so a hand-written or PARTIAL block (one setting, say, only `server.cache`) left it undefined. Unknown `${...}` tokens are preserved verbatim and silently by design, so the `server.credentials` paths rendered as `.../certificates/scopes/<scope>/${host}/private.key`, the https/HTTP-2 read ENOENT'd, and the boot exited pointing at the server settings rather than at the missing key. The env template now carries the same `localhost` default the CLI already writes, so the existing deep-fill merge supplies it; an omitted declaration is warned (naming bundle + env) instead of defaulting silently, and a project that declares `host` is untouched because the merges are target-wins. NOTE the merge was EXONERATED as the cause: every env.json merge is override-off, i.e. a deep target-wins FILL — the shallow `override=true` wholesale-replace exists only on the dev-mode `env.local.json` path, a different surface. **SIBLING — the declared-but-not-for-this-env shape (#B183, 2026-08-01, 0.6.2).** `newContent[app][env].bundlesPath` was assigned one line BEFORE the `typeof(content[app][env])` guard that exists to protect exactly that dereference; since `newContent` is a deep clone of `content`, that property is undefined for precisely the apps the guard rejects. Booting an env a declared bundle carries no block for therefore threw `Cannot set properties of undefined (setting 'bundlesPath')` at exit 143, ~750 lines before (b)'s refusal could fire — (b)'s coverage of this fourth shape was replica-only and never live-verified, which is why it read as covered. The assignment now sits INSIDE the guard: the STARTING bundle reaches (b)'s named refusal (exit 1, naming bundle + env + env.json path), and a NON-starting bundle skipped for the booting env is WARNED (naming bundle + env) rather than vanishing from the boot silently — the guard's else branch was previously empty, so a skipped sibling left no trace. Live A/B in both directions on fresh isolated scenes (control boots; mutated scene died at `config.js:786` before, refuses cleanly after). **And a DELETED env.json does not stay deleted:** the CLI recreates it as `{}` on the next command of any kind, and `{}` is precisely the (b) shape — so the historical "the crash persists across rebuilds" reading was that resurrection, NOT a stale env bake in the release tree; `bundle:build` writes no env config there at all (measured).
954
+ 260. **A bundle with a missing or unreadable config/routing.json REFUSES to boot (matching the malformed-JSON path), and the route-lookup not-found error names the bundle + its rule count (2026-07-20, 0.5.21).** Bundle config hydration is readdir-driven: a routing.json absent at boot readdir time was never iterated, so the bundle silently booted with only the framework-synthetic routes (bundle-status, custom-error-page, webroot + the upload endpoints) — every app route 404'd and a sibling bundle's cross-bundle route lookup threw hours later with a message indistinguishable from a mistyped rule; there is no prod re-hydration. The config loader now tracks whether the config-file loop resolved a REAL routing config (bundle dir or shared-config complement; env-versioned `routing.<env>.json` variants ride their base file's iteration, so an env-only file still refuses — it never loaded anyway) and otherwise fails the boot with an error naming the bundle + environment; an EXTERNAL supervisor restart (an orchestrator liveness/restart, a container restart policy such as restart=always, an init system) then retries until the release tree settles, so a mid-deploy race self-heals — gina's own daemon does NOT retry a startup crash: it reports `crashed during startup` once and stops, so a bare `gina bundle:start` deployment is fail-stop until a manual restart. BEHAVIOR CHANGE: a deployment that relied on the silent half-boot must fix its release artifact. The `RoutingHelper::getRouting` not-found throw keeps its historical prefix and appends `(bundle <name> holds N rules)`, so a degraded or near-empty table is tellable from a plain mistyped rule — browser bundle included (dist rebuilt). **SIBLING REFUSAL — the project `env.json` block (#B181(b), 2026-07-31, 0.6.2).** The same function refuses one step earlier when `envConf[bundle][env]` is absent: the bundle list is built inside the env.json template-merge loop, so an env.json that is ABSENT (`userConf` stays `false`), empty, or that declares only bundles the manifest does not list yields an EMPTY list — `bundles[b]` is then undefined, `bundle` falls back to `startingApp`, and `setServerCoreConf` dereferenced a block that was never populated, dying as an uncaughtException (`Cannot read properties of undefined (reading '<env>')`, exit 143) that named neither env.json nor the bundle. It now refuses through the same `callback(new Error())` path (exit 1), naming the bundle, the env, the resolved env.json path, and whether the file was found at all — so a MISSING file and a present-but-incomplete one give different remedies; `setServerCoreConf` also throws a named error for any direct caller. Measured on fresh isolated scenes: the scaffolded-env.json baseline still boots and serves, and all three degenerate shapes refuse cleanly — the predicate can only fire where the boot ALREADY crashed, so it cannot refuse a working configuration. **SIBLING — the missing `host` default (#B181(a), 2026-07-31, 0.6.2).** `host` was the ONLY key the per-bundle configuration reads that carried no framework-side default — absent from both the env and settings templates, and written solely by the CLI into the project's own env.json — so a hand-written or PARTIAL block (one setting, say, only `server.cache`) left it undefined. Unknown `${...}` tokens are preserved verbatim and silently by design, so the `server.credentials` paths rendered as `.../certificates/scopes/<scope>/${host}/private.key`, the https/HTTP-2 read ENOENT'd, and the boot exited pointing at the server settings rather than at the missing key. The env template now carries the same `localhost` default the CLI already writes, so the existing deep-fill merge supplies it; an omitted declaration is warned (naming bundle + env) instead of defaulting silently, and a project that declares `host` is untouched because the merges are target-wins. NOTE the merge was EXONERATED as the cause: every env.json merge is override-off, i.e. a deep target-wins FILL — the shallow `override=true` wholesale-replace exists only on the dev-mode `env.local.json` path, a different surface. **SIBLING — the declared-but-not-for-this-env shape (#B183, 2026-08-01, 0.6.2).** `newContent[app][env].bundlesPath` was assigned one line BEFORE the `typeof(content[app][env])` guard that exists to protect exactly that dereference; since `newContent` is a deep clone of `content`, that property is undefined for precisely the apps the guard rejects. Booting an env a declared bundle carries no block for therefore threw `Cannot set properties of undefined (setting 'bundlesPath')` at exit 143, ~750 lines before (b)'s refusal could fire — (b)'s coverage of this fourth shape was replica-only and never live-verified, which is why it read as covered. The assignment now sits INSIDE the guard: the STARTING bundle reaches (b)'s named refusal (exit 1, naming bundle + env + env.json path), and a NON-starting bundle skipped for the booting env is WARNED (naming bundle + env) rather than vanishing from the boot silently — the guard's else branch was previously empty, so a skipped sibling left no trace. Live A/B in both directions on fresh isolated scenes (control boots; mutated scene died at `config.js:786` before, refuses cleanly after). **And a DELETED env.json does not stay deleted:** the CLI recreates it as `{}` on the next command of any kind, and `{}` is precisely the (b) shape — so the historical "the crash persists across rebuilds" reading was that resurrection, NOT a stale env bake in the release tree; `bundle:build` writes no env config there at all (measured). **SIBLING — the DISCARDED release-path error (#B372, 2026-08-15, unreleased).** The fourth shape in this class, and the only one where the framework had already computed the right message and then threw it away. `loadWithTemplate` links each registered bundle's release target for a non-dev env and, on failure, calls back with a well-formed `[ releaseError ]` carrying the path — via `return callback(_releaseError)`, i.e. ONE argument, so `envConf` arrives `undefined`. `Env.load`'s callback never inspected `err`: its first statements were `self.envConf = envConf` then `envConf.env = self.env`, so the real reason was dropped and the boot died on `TypeError: Cannot set properties of undefined (setting 'env')` as an uncaughtException naming neither the bundle nor the path — the server never binds, so a startup probe sees only `connection refused`. Because the config load is SHARED, ONE bundle's missing release tree took down EVERY bundle in the project. ⚠️ Propagating alone is NOT sufficient — `getConf`'s callback did not inspect `err` either, so a bare `if (err) return callback(err)` only moves the failure; the error now also gets a SINK mirroring the `loadBundlesConfiguration` refusal ~45 lines below it in the same function (log + a synchronous `fs.writeSync(2, …)` so the reason survives `process.exit()` on an async pipe, then exit 1). The message now names the bundle, env and scope, and deliberately no longer reports `targetAppPathObj`: when the throw comes from `pkg[app].releases[scope][env]` itself, that assignment never ran and the object still holds the LINK path, so the old message named the wrong file in precisely the missing-release-entry case. ⚠️ **Omitting `releases[<scope>]` is NOT how you mark a bundle undeployed** — it hits this same throw rather than skipping, and both build verbs re-seed it anyway. The supported way is the `bundles[<name>].scopes` allow-list (**#306**, shipped alongside this fix); before it existed, a per-scope opt-out could only be modelled by leaving the bundle out of `manifest.bundles` entirely, which removed it from EVERY scope.
955
955
 
956
956
 
957
957
  262. **Merged-process cross-bundle routing clobber (#B137).** In a merged-process project (several bundles sharing ONE port — `isStandalone` stays true and one process serves the project), `Env.getConf`'s merged branch permanently reassigned the TARGET bundle's `content.routing` to the STARTING app's table (a reference assignment surviving for the process lifetime, a 2015 single-table-era fossil). Under per-bundle `rule@bundle` keying the starting app's table cannot hold a sibling's keys, so the FIRST cross-bundle getUrl both inflicted the clobber and failed: every cross-bundle link rendered the literal `404:[<METHOD>]<rule>@<bundle>` marker (the not-found message betrays it — the sibling "holds N rules" where N is the STARTING app's count), and merged-mode inbound statics dispatch (which resolves a statics-prefix-matched sibling's table through the same slot) could match requests against the wrong bundle's table. Fixed by dropping the reassignment: each bundle keeps its own table (the hostname share stays — same host:port in merged mode by definition); the merged view of all bundles' rules lives in the global `envConf.routing`. Distinct-port projects always took the clean branch and were never affected; single-bundle projects saw only a benign self-assign. Server-side only — no browser-bundle rebuild.
@@ -973,7 +973,7 @@ Dev-mode query instrumentation captures every database query tied to the current
973
973
 
974
974
  275. **The `controller:` CLI group — `add` scaffolds, `remove`/`rm` reference-awarely deletes, `rename` reference-awarely renames a namespace controller (#R9, 2026-07-23).** `gina controller:add <name> <bundle> @<project> [--controls=a,b,c] [--api|--views]` — a new OFFLINE CLI group (`lib/cmd/controller/`, registered in `bin/cli` allowedOffline; a develop install serves the verb immediately, npm consumers from 0.5.25). Generates `controllers/controller.<name>.js` with one JSDoc'd action stub per `--controls` entry (omitted → a single `default` action — the `core/template/boilerplate/bundle_namespace` seed's own action name). Bundle FLAVOR auto-detected via `config/templates.json` presence (config.js's `hasViews` signal, offline-readable without a boot): a VIEW bundle → `self.render()` stubs + one template per action at `templates/html/<name>/<action>.html`; an API-only bundle → `self.renderJSON()` stubs, no templates. `--api`/`--views` force it (both → error). Route wiring is PRINT-ONLY: prints paste-ready rules keyed `<name>-<action>` carrying `namespace` + `param.control`, and for a VIEW rule an explicit `param.file: "<action>"` — render-swig.js:364-383 strips a `<namespace>-` prefix from a DEFAULTED `param.file` (which equals the rule name) and emits a per-request "does not respect gina naming convention" WARN, so the explicit file resolves `templates/html/<ns>/<action>.html` warn-free (the boilerplate `homepage` rule sidesteps it with a non-prefixed name); the `default` action's URL is the namespace root `/<name>`, else `/<name>/<action>`. Refuses an existing `controller.<name>.js` (no `--force` overwrite — remove it first), and validates the namespace charset `/^[a-z][a-z0-9_]*$/i` BEFORE any path build (`controller` reserved case-insensitively; the value is interpolated into file paths, a RegExp and a JS class name, so a loose value is an injection vector). Positionals come from the group's own pure `inc/args.js` parser (`opt.argv.slice(3)` minus `@project`/`--flags`) — CmdHelper's clean positional cleanup is `bundle:`-ONLY (helper.js:482), so `self.bundles` for any other topic is contaminated with the project's OTHER bundles and cannot be read for `<name> <bundle>`. Pure `inc/` helpers (`namespace` charset+className, `scaffold` parseControls/buildRules/formatRulesBlock/renderController/renderTemplate, `args` positionals) are require-by-path unit-tested behaviourally; the paste-ready report prints via flush-safe `fs.writeSync(1, …)` (the connector:infer precedent — a `console.log`/`process.stdout.write` before `process.exit` truncates on a pipe). Boot-verified end-to-end in an isolated home: the scaffolded view controller serves HTTP 200 with the rendered body and NO naming-convention warn. Tests: `test/lib/controller-add.test.js` (38). **`controller:remove <name> <bundle> @<project> [--dry-run] [--force] [--format=json]`** (alias `controller:rm` = `module.exports = require('./remove')`) is REFERENCE-AWARE: a namespace with no matching `controller.<name>.js` doesn't error — `router.js:521-525` WARNs and silently falls back to the default `controller.js` (a misdispatch), so a bare delete is unsafe. The pure `inc/reference-scan.js` (node `fs`/`path` only, no framework globals) scans the four measured reference sites — the controller file, routing.json rule-level `namespace` AND `param.namespace` (both load a controller; the body is comment-stripped with a STRING-AWARE regex so a `$schema` URL survives, and non-object entries are skipped like `config.js:2029`), and `requireController('<name>')` literals (both quote styles) across the bundle's `.js` tree — then REFUSES with a per-file blocker list unless clean (routing rules + external `requireController` calls block; a self-reference inside the file being deleted is moot). Clean → interactive readline yes/no (the `view:add` idiom; a non-TTY stdin aborts, naming `--force`/`--dry-run`) → deletes the controller file + its `templates/html/<name>/` tree. It NEVER edits routing.json. `--force` deletes the file+templates even with blockers (listing what remains to clean by hand), `--dry-run` previews, `--format=json` emits an envelope (`{controllerFile,templateDir,routingRefs,requireRefs,dynamicRefs,blocking,removable,removed}`) and deletes only with `--force`. Dynamic references a static scan cannot resolve (`param.namespace` `:variable`, non-literal `requireController(expr)`) are surfaced as an ADVISORY note, never silently cleared. The default `controller.js` can never be removed (`controller` is reserved). Deletion-plan file counts are taken BEFORE the delete. Tests: `test/lib/controller-remove.test.js` (34). **`controller:rename <old> <new> <bundle> @<project> [--dry-run] [--force] [--format=json]`** reuses the scanner + adds the pure `inc/reference-rewrite.js` (also fs/global-free): it moves `controller.<old>.js` → `controller.<new>.js`, moves `templates/html/<old>/`, and rewrites the old namespace at the two ANCHORED sites — routing.json `"namespace": "<old>"` VALUES (rule-level AND param.namespace; a QUOTED-STRING-anchored `replace`, NOT a JSON parse→stringify, so comments/ordering/whitespace survive byte-for-byte, and a substring like `checkout2` or a `:variable` value is never matched) and every `requireController('<old>')` literal (both quote styles, spacing preserved). Unlike `remove`, rename DOES edit routing.json (only those values). A full plan is shown, then an interactive readline confirm (non-TTY aborts naming `--force`); apply is ALL-OR-NOTHING — snapshot every write+move, roll back on any failure. `--force` applies without the prompt, `--dry-run` previews, `--format=json` emits an envelope and applies only with `--force`. Residuals it REPORTS-not-rewrites: `:variable`/non-literal dynamic refs and the cosmetic `<Bundle><Namespace>Controller` class name (gina loads by file path, so it is a label, not a reference). Target collision (`controller.<new>.js` or `templates/html/<new>/` exists) hard-refuses — no overwrite. Tests: `test/lib/controller-rename.test.js` (18).
975
975
 
976
- 276. **Worker-global proxy-context poisoning by port-less internal calls — the `getUrl`/`url` template filters, the cross-bundle redirect-by-URL target and the error-fallback redirect now prefer the emitting request's own classification; opt-in `server.proxy.requireForwardedHeaders` makes classification deterministic (#B152).** A request whose inbound Host carries no `:port` is classified reverse-proxied (the first arm of the per-request classification, both engines) and REFRESHES `process.gina.PROXY_HOST`/`PROXY_HOSTNAME` — but a port-less Host is also what an internal call addressed by service/DNS name carries (a container health probe on an app route — only `/_gina/health/check` itself, `$`-anchored, bypasses the classification block — a mesh hop, a sibling-bundle request), so ONE such call repointed every later worker-global read at the internal host: both `getUrl` filter branches (the path/bundle-override `hostname` composition AND the post-`getRoute` `proxy_hostname` override feeding `toUrl()`), the redirect-by-URL `getRoute(path).toUrl()` branch, and `throwError`'s route-object fallback. On a proxy-less fresh worker the same call CREATES proxy mode permanently (the composite latch arms once the global exists). Fixed server-side, NO dist rebuild (`getUrl` is not browser-bundled — the dist's `getUrl` hits are `getUrlProps`, a substring artifact): all four readers prefer THIS request's per-request slots (`req._ginaIsProxyHost` strict-`=== true` + `req._ginaProxyHostname`) over the worker-global, which stays the fallback for renders with no request of their own — a raw victim resolves the config host despite the latch, a proxied victim emits its own host, a slot-less caller is byte-identical; `core/router.js` fills the slots when ABSENT so the Express engine (which never set them) gets per-request truth engine-agnostically, isaac's earlier identical classification always winning. The opt-in key (settings.json + published schema; strict boolean, fail-safe false, boot-resolved once, `console.info` when armed) disables the port-less heuristic so ONLY X-Forwarded-Host classifies as proxied — the one mechanism that also protects req-less renders; enable it only behind a front proxy that always sends X-Forwarded-Host. Two mechanism corrections vs the field report that surfaced this: `PROXY_PORT` is boot-static (never request-refreshed; the boot scheme-switch forces it to 80/443, clobbering any proxy.json-hostname port), so a nonstandard port in an emitted URL comes from the victim request's OWN normalized `headers.port`/`[':port']`, never the poisoner; and moving container probes off `/_gina/health/check` onto app routes is what acquires the bug — nothing signals it. Pickup: restart (server-side only). Tests: `test/core/proxy-request-scope.test.js` (44, incl. a real `getRoute`/`toUrl` subtract reproducing the internal-host emission pre-fix). **Falsy proxy-host state degrades the URL builder, never crashes it (former #291, #B168, 0.6.0):** server-side `getRoute()` resolves `route.proxy_hostname` from the worker global with an envConf fallback, and BOTH are framework-produced falsy states (the global is boot-set only from a proxy config carrying a hostname and otherwise first written by a proxied request; the envConf fallback is deliberately written null for a direct-classified render) — with the worker-wide proxied latch true and both unset, the unguarded rewrite threw `TypeError: Cannot read properties of null (reading 'replace')` on EVERY server-side `getRoute()` while the state lasted: URL building for mail/workers, redirects and health checks alike (observed live: 72 identical failures through a readiness probe, healing in-process once a later render rewrote the envConf fallback). The truthiness guard falls back to the route's direct hostname AND flips `route.isProxyHost` false — `toUrl()` keys on that flag, so a bare skip would stringify the unset value into the emitted URL, converting a loud crash into silently wrong links; a once-per-process warning names the degraded state. The `url` template filters hold `getRoute()`'s own resolution and degrade only when nothing resolved anywhere (their per-request override used to re-force the flag with an unresolved value in slot-less renders, undoing the fix — and replacing a usable envConf-derived value with an unset one); the non-proxied branch's gate requires TRUTHY (the old `typeof` admitted a defined-but-falsy global — typeof null is 'object'). Browser bundle changed (`lib/routing` ships in it) with zero client behavior change — the client arm resolves from `window.location`, which cannot be unset. Rule: a framework-produced falsy state must DEGRADE the builder, and the degrade must flip the flag downstream code keys on. **#B367 (0.6.8) — those same headers are now VALIDATED WHERE THEY ARE READ, because until 0.6.8 they reached the browser unescaped and were a reflected-XSS vector.** `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Prefix` and the caller's own `Host`/`:authority` feed `page.environment.hostname` / `.webroot` / `.proxyHost` / `.proxyHostname`, which the client bootstrap (`gina.onload.min.js`) carries in SINGLE-QUOTED JS string literals (`hostname:'{{ … }}'`, `window.__ginaWebroot='{{ … }}'`). The substitution is `whisper(dic, layout, /\{{ ([a-zA-Z.]+) \}}/g)` — whisper's rule branch is `replaceable.replace(rule, (s,key) => dictionary[key] || s)`, a RAW splice that escapes nothing — and the loader is injected AFTER template compilation precisely so the engine never sees those tokens. So a single quote in one header closed the literal and executed attacker script on every rendered page, unauthenticated (reproduced live over HTTP on both the host and prefix vectors). The guard is at INGEST, not emission, and that choice is load-bearing: whisper runs over the WHOLE layout, so the same dictionary also feeds HTML contexts where a JS-string escape would emit literal backslashes; and the sibling values already safe (`forms`, `routing`, `validatorLabels`) use `encodeRFC5987ValueChars` + a client `decodeURIComponent`, so extending that pattern would have changed the browser bundle and billed every consumer a re-bake for a server-side flaw. A host must be `name[:port]` or a bracketed IPv6 literal (`/^[A-Za-z0-9._:\[\]-]+$/`, ≤255), a forwarded scheme must be exactly `http`/`https`, a prefix must be URL-path characters; anything else is REFUSED and the request falls back to the bundle's configured host/webroot as if the header were absent — including the classification itself, so a malformed `X-Forwarded-Host` no longer marks the request proxied. The guard is TWINNED in `core/server.isaac.js` and `core/router.js` (the same deliberate twinning as the classification above) and a test pins the two character classes EQUAL, because a drift makes one engine exploitable again. Known consequence: a comma-separated `X-Forwarded-Host` (chained proxies) now fails the guard and falls back — pre-fix it produced `scheme://a.example, b.example`, i.e. already garbage; splitting the list is deliberately NOT done, since element 0 is the original client's own value and taking it would reopen the vector, and any other element needs a trusted-hop count the framework does not have. Server-side only ⇒ pickup is a bundle RESTART, no re-bake. Rule: a request header that will be interpolated into a client-side script is untrusted input at the READ site, and the cheapest correct place to reject it is where it enters — not where it is emitted.
976
+ 276. **Worker-global proxy-context poisoning by port-less internal calls — the `getUrl`/`url` template filters, the cross-bundle redirect-by-URL target and the error-fallback redirect now prefer the emitting request's own classification; opt-in `server.proxy.requireForwardedHeaders` makes classification deterministic (#B152).** A request whose inbound Host carries no `:port` is classified reverse-proxied (the first arm of the per-request classification, both engines) and REFRESHES `process.gina.PROXY_HOST`/`PROXY_HOSTNAME` — but a port-less Host is also what an internal call addressed by service/DNS name carries (a container health probe on an app route — only `/_gina/health/check` itself, `$`-anchored, bypasses the classification block — a mesh hop, a sibling-bundle request), so ONE such call repointed every later worker-global read at the internal host: both `getUrl` filter branches (the path/bundle-override `hostname` composition AND the post-`getRoute` `proxy_hostname` override feeding `toUrl()`), the redirect-by-URL `getRoute(path).toUrl()` branch, and `throwError`'s route-object fallback. On a proxy-less fresh worker the same call CREATES proxy mode permanently (the composite latch arms once the global exists). Fixed server-side, NO dist rebuild (`getUrl` is not browser-bundled — the dist's `getUrl` hits are `getUrlProps`, a substring artifact): all four readers prefer THIS request's per-request slots (`req._ginaIsProxyHost` strict-`=== true` + `req._ginaProxyHostname`) over the worker-global, which stays the fallback for renders with no request of their own — a raw victim resolves the config host despite the latch, a proxied victim emits its own host, a slot-less caller is byte-identical; `core/router.js` fills the slots when ABSENT so the Express engine (which never set them) gets per-request truth engine-agnostically, isaac's earlier identical classification always winning. The opt-in key (settings.json + published schema; strict boolean, fail-safe false, boot-resolved once, `console.info` when armed) disables the port-less heuristic so ONLY X-Forwarded-Host classifies as proxied — the one mechanism that also protects req-less renders; enable it only behind a front proxy that always sends X-Forwarded-Host. Two mechanism corrections vs the field report that surfaced this: `PROXY_PORT` is boot-static (never request-refreshed; the boot scheme-switch forces it to 80/443, clobbering any proxy.json-hostname port), so a nonstandard port in an emitted URL comes from the victim request's OWN normalized `headers.port`/`[':port']`, never the poisoner; and moving container probes off `/_gina/health/check` onto app routes is what acquires the bug — nothing signals it. Pickup: restart (server-side only). Tests: `test/core/proxy-request-scope.test.js` (44, incl. a real `getRoute`/`toUrl` subtract reproducing the internal-host emission pre-fix). **Falsy proxy-host state degrades the URL builder, never crashes it (former #291, #B168, 0.6.0):** server-side `getRoute()` resolves `route.proxy_hostname` from the worker global with an envConf fallback, and BOTH are framework-produced falsy states (the global is boot-set only from a proxy config carrying a hostname and otherwise first written by a proxied request; the envConf fallback is deliberately written null for a direct-classified render) — with the worker-wide proxied latch true and both unset, the unguarded rewrite threw `TypeError: Cannot read properties of null (reading 'replace')` on EVERY server-side `getRoute()` while the state lasted: URL building for mail/workers, redirects and health checks alike (observed live: 72 identical failures through a readiness probe, healing in-process once a later render rewrote the envConf fallback). The truthiness guard falls back to the route's direct hostname AND flips `route.isProxyHost` false — `toUrl()` keys on that flag, so a bare skip would stringify the unset value into the emitted URL, converting a loud crash into silently wrong links; a once-per-process warning names the degraded state. The `url` template filters hold `getRoute()`'s own resolution and degrade only when nothing resolved anywhere (their per-request override used to re-force the flag with an unresolved value in slot-less renders, undoing the fix — and replacing a usable envConf-derived value with an unset one); the non-proxied branch's gate requires TRUTHY (the old `typeof` admitted a defined-but-falsy global — typeof null is 'object'). Browser bundle changed (`lib/routing` ships in it) with zero client behavior change — the client arm resolves from `window.location`, which cannot be unset. Rule: a framework-produced falsy state must DEGRADE the builder, and the degrade must flip the flag downstream code keys on. **#B367 (0.6.8) — those same headers are now VALIDATED WHERE THEY ARE READ, because until 0.6.8 they reached the browser unescaped and were a reflected-XSS vector.** `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Prefix` and the caller's own `Host`/`:authority` feed `page.environment.hostname` / `.webroot` / `.proxyHost` / `.proxyHostname`, which the client bootstrap (`gina.onload.min.js`) carries in SINGLE-QUOTED JS string literals (`hostname:'{{ … }}'`, `window.__ginaWebroot='{{ … }}'`). The substitution is `whisper(dic, layout, /\{{ ([a-zA-Z.]+) \}}/g)` — whisper's rule branch is `replaceable.replace(rule, (s,key) => dictionary[key] || s)`, a RAW splice that escapes nothing — and the loader is injected AFTER template compilation precisely so the engine never sees those tokens. So a single quote in one header closed the literal and executed attacker script on every rendered page, unauthenticated (reproduced live over HTTP on both the host and prefix vectors). The guard is at INGEST, not emission, and that choice is load-bearing: whisper runs over the WHOLE layout, so the same dictionary also feeds HTML contexts where a JS-string escape would emit literal backslashes; and the sibling values already safe (`forms`, `routing`, `validatorLabels`) use `encodeRFC5987ValueChars` + a client `decodeURIComponent`, so extending that pattern would have changed the browser bundle and billed every consumer a re-bake for a server-side flaw. A host must be `name[:port]` or a bracketed IPv6 literal (`/^[A-Za-z0-9._:\[\]-]+$/`, ≤255), a forwarded scheme must be exactly `http`/`https`, a prefix must be URL-path characters; anything else is REFUSED and the request falls back to the bundle's configured host/webroot as if the header were absent — including the classification itself, so a malformed `X-Forwarded-Host` no longer marks the request proxied. The guard is TWINNED in `core/server.isaac.js` and `core/router.js` (the same deliberate twinning as the classification above) and a test pins the two character classes EQUAL, because a drift makes one engine exploitable again. Known consequence: a comma-separated `X-Forwarded-Host` (chained proxies) now fails the guard and falls back — pre-fix it produced `scheme://a.example, b.example`, i.e. already garbage; splitting the list is deliberately NOT done, since element 0 is the original client's own value and taking it would reopen the vector, and any other element needs a trusted-hop count the framework does not have. Server-side only ⇒ pickup is a bundle RESTART, no re-bake. ⚠️ **Field reachability (consumer-reported 2026-08-15, and it INVALIDATED our own published check): `X-Forwarded-Prefix` is the header that actually exposes people, and the obvious audit question misses it.** We had told operators to ask whether their proxy SETS the `X-Forwarded-*` headers from its own knowledge or FORWARDS what the client sent. That question is a FALSE-NEGATIVE GENERATOR: a conscientious edge config sets `Host`/`X-Forwarded-Host`/`X-Forwarded-Proto`/`X-Forwarded-For` and never MENTIONS `X-Forwarded-Prefix` (a mount path is a concern most deployments never set deliberately), and nginx forwards any header it does not explicitly override — so the prefix travels verbatim while the other three are correctly replaced, and the operator answers "we overwrite them, we're fine" and is WRONG. Reported on a two-layer nginx topology (both layers setting Host + proto, outer setting X-Forwarded-Host, NEITHER naming the prefix — measured 0 with firing controls). **Verified first-hand here at the `v0.6.7` tag: the prefix block's only enclosing scope is `server.on('request', …)` — there is NO proxy-classification gate on it at all**, so the prefix is accepted whether or not the request counts as proxied, and correctly overriding `X-Forwarded-Host` cannot close it; the gap is sufficient alone. Ask the question PER HEADER ("is `X-Forwarded-Prefix` named in this config?") at EVERY layer, never as a blanket `X-Forwarded-*` question. Rule: a request header that will be interpolated into a client-side script is untrusted input at the READ site, and the cheapest correct place to reject it is where it enters — not where it is emitted. Second rule, from the reachability miss: when you hand operators a check for whether a flaw can reach them, make it name EACH input separately — a check phrased over a family of headers silently assumes the proxy treats the family uniformly, and proxies do not.
977
977
  277. **Render/output cache — pluggable backends, release-namespaced keys, event invalidation, flush, Cache-Status (consolidates former #234/#235/#237/#238/#242/#244; 0.5.18–0.5.22).** The framework caches rendered responses under two key namespaces — `static:` HTML + `data:` JSON — in the multi-purpose server Map (which ALSO holds `swig:` compiled templates + `http2session:` sessions: every bulk operation scopes to the output namespaces ONLY). CONFIG: bundle-wide defaults on `server.cache` (`enable` is the hard gate for writer AND reader; plus `path`/`ttl`/`sliding`/`maxAge`); `type`/`store` come from the bundle `config/settings.json` top-level `cache` block, folded into the runtime `server.cache` at config-load (env.json keys win; without the fold the documented default was inert); per-route via a routing.json rule's top-level `cache: {…}` with FILL-only inheritance — a per-route value always wins, and a route that sets `type` at all keeps it (only an OMITTED `type` inherits the bundle default). STRATEGIES: `memory` (inline content); `fs` (body file + a `.meta` sidecar carrying created/ttl/sliding/maxAge/headers/events; restart READ-BACK on an index miss with ABSOLUTE expiry preserved — a restart never extends a TTL; pure-sliding read-back counts as a fresh access, a documented imprecision); `redis` two-tier (L1 = the in-process Map written synchronously; L2 = shared redis via fire-and-forget `PSETEX` — the response never waits; `warm()` repopulates L1 with the AUTHORITATIVE remaining PTTL and re-registers the route's events so a warmed entry stays evictable; fail-open with one once-per-process warn + a 1s `commandTimeout` so a blackholed socket cannot hang the render hot path; a graceful shutdown leaves L2 INTACT — peer replicas and the restarting replica warm from it; every redis route needs a ttl or `invalidateOnEvents` — boot-enforced fail-fast, with proportionality: an `enable:false` bundle never opens a connection and would-be-fatals downgrade to loud warns). KEYS: one `buildKey` owns `[<token>:]<kind>:<bundle>:<url>` at every writer AND reader (the format was previously duplicated at 4 sites and drifted); the token (`GINA_CACHE_NAMESPACE || GINA_VERSION`, sanitized) release-namespaces every cache so a framework upgrade auto-invalidates, and the token is ALSO an fs PATH segment — a measured requirement, not cosmetics: with the token only in the key, a namespace change reused the same file path and read-back served stale cross-namespace bytes. EVENTS: routes register `cache.invalidateOnEvents`; fire from a controller via `self.cache.invalidateByEvent(event)` (a deliberately NARROW facade — it never exposes `from()`/`set()`, and reaches the OWN process only) or cross-bundle via `gina cache:clear @<project> --event=<name>` / `POST /_gina/cache/clear?event=<name>`; TTL/sliding expiry runs the SAME eviction as an explicit delete (cleanup fns fire + registrations reclaim), and fs/redis persistence re-registers events on read-back/warm. FLUSH: the CLI runs the offline fs reclaim FIRST (works with the bundle down; reclaims prior-release orphan dirs; never touches the reserved `config`/`swig` infra-cache dirs) then the in-heap endpoint (admin-gated, POST-only — a flush is a mutation a prefetch must not fire; `event` WINS over `bundle`); `--dry-run` previews the fs set + probes reachability read-only and deliberately claims NO count. READ PATH: engine-agnostic in the shared request handler (express included — previously isaac-only), so a fresh replica's FIRST GET can serve a peer replica's write; every hit carries RFC 9211 `Cache-Status: <name>; hit; ttl=N; detail=memory|redis|fs` (naming the physical tier that served the bytes) and every genuine miss `<name>; fwd=uri-miss` — `<name>` = `server.cache.name` when set to a valid token (#B238: a letter then up to 63 of `[A-Za-z0-9._-]`, a conservative RFC 8941 subset; an invalid value boot-warns and falls back), else the default `gina-cache`, resolved ONCE at boot (server.js stamps `instance._cacheName`; all mint sites on both engines read the stamp, so the identifier cannot disagree across engines or hit/miss) — and when `server.hidePoweredBy` is true + the cache enabled + no name set, boot warns that the wire still names the framework (warn-not-flip: the identifier is a documented-stable wire value — set any token e.g. `cache` to close the disclosure, or explicit `gina-cache` to silence); cached routes re-mint CSP nonces on hits (#B130 — header + body, stored entries never mutated). **A gated route is never cached** — pairing `cache` with `param.requireAuth`/`roles`/`policy` (or leaving it mode-gated) REFUSES the boot, and the delegates skip the write regardless: the key carries no principal and both serve points precede the authorization gate (#B158). Server-side only — none of this ever needs a dist rebuild. Tests: `test/lib/render-cache.test.js`, `test/core/server-render-cache-read.test.js`, `test/core/server-render-cache-boot.test.js`, `test/lib/cache-clear.test.js`; consumer guide: the docs-site caching guide.
978
978
 
979
979
  278. **A cache key is opaque DATA — never route it through an expression/condition evaluator.** An output-cache key embeds the request URL, so a querystring's `?`/`=` parse as OPERATOR tokens in any condition-DSL lookup — the render cache's event registry originally lived in a collection whose dedup ran keys through the condition evaluator, and the SECOND cache-miss render of any cached GET route with `invalidateOnEvents` + a querystring THREW (in the swig path that rejection unwound to the function-level try, whose catch answered 500 — discarding an already-rendered page). The registry became a plain array matched with `===`. Generalizes beyond caching: never let a value carrying user-controlled characters reach a query/condition DSL as an expression — match registry rows with `===` on plain structures, and treat every composite key as an opaque string.
@@ -1009,4 +1009,7 @@ Dev-mode query instrumentation captures every database query tied to the current
1009
1009
  302. **The `stream` storage strategy (`lib/storage/src/local-stream`, #STO1) — large sequential media with RESUMABLE uploads.** Keys name an ASSET, not a file: `assets/<ulid>/original<ext>` — `original` is the reserved base-rendition name and the per-asset directory reserves the namespace future renditions land in (no rendition API in this release). Keys stay OPAQUE; never parse the directory back out. What per-asset colocation buys is OPERATIONAL grouping (one `rm -rf` per asset, one `rsync` of a subtree), NOT physical locality — a directory does not place extents, the allocator does — and gina neither prevents nor CAN prevent fragmentation: Node exposes no `fallocate` binding, native addons are not shipped, and `ftruncate` yields a fully SPARSE file (measured: a 256MB truncate reserves 0 blocks, and truncating past the free space succeeds, so it delivers neither anti-fragmentation nor an early ENOSPC). Contiguity is a filesystem/volume concern (XFS extent hints and the like); `chunkSize` (default `'8MB'`, `parseSize`) is the write stream's `highWaterMark` and nothing more. **Five capability-gated verbs beyond the shared contract** (`capabilities.resumable` true — branch on the flag, never probe): `createUpload(meta, cb)` -> `{uploadId, chunkSize, expectedSize}` / `writeSegment(uploadId, offset, stream, cb)` -> `{offset, length, received}` / `statUpload(uploadId, cb)` -> `{expectedSize, contentType, originalName, createdAt, received[], missing[], complete}` / `finalize(uploadId, cb)` -> `{key, size, contentType}` (put()'s shape) / `abortUpload(uploadId, cb)` -> `{aborted}`, idempotent. `statUpload` is NOT optional — resume is impossible unless a client can learn what landed; it is the resumable twin of `stat()`, and being computed from the session's own files it stays accurate across a process restart with no in-memory session table. **`expectedSize` is REQUIRED at create**: without a declared total finalise cannot verify coverage and `statUpload` cannot say what is MISSING (only what arrived), so a caller who does not know the size uses `put()` — the same exclusion tus takes without its defer-length extension, and strictly more useful than S3 multipart's `ListParts`, which can never name a missing part because it never learns the total. The declared size also permits an early `maxObjectSize` refusal before any byte moves, **advisory only**: it is client-supplied, so the running byte counter in `writeSegment` (which refuses a segment running past the declared end) is the real enforcement. **The KEY is minted at `createUpload` and persisted in the manifest**, never invented at finalise — minting it late means a row write that fails after the bytes are published leaves them at an address nobody holds, an unreachable orphan PLUS a session that can never finalise again. **The filesystem IS the manifest**, and the seam gains ZERO new verbs: a session is `<root>/.uploads/<uploadId>/` holding `data` (assembled IN PLACE via positional writes, `flags:'r+'` + `start`, so segments may arrive out of order and no assembly pass runs at finalise — measured, a segment-per-file layout costs ~1.9 s/GB of extra concat I/O where this costs ~4ms), `meta.json` (written ONCE through temp+rename) and one zero-byte `<offset>-<length>.ok` marker per DURABLE range. `.uploads` is a dot segment, structurally uncollidable with any caller key (the key guards reject dot segments), like `.tmp` and the `.driver` stamp. The argument for that home is FATE-SHARING, not seam economy: `fsync(data)` happens-before the marker is created, which is only enforceable while both live on the same medium — a store-backed manifest (SQLite WAL, redis, couchbase) can survive a crash the bytes did not, and a manifest claiming a range the bytes lost passes coverage and publishes ZEROS. **Coverage is an INTERVAL UNION, and that is the whole safety argument.** A hole in a sparse file reads back as ZEROS rather than erroring (measured), so an unverified finalise publishes a plausible, silently corrupt object; and SUMMING marker lengths is not enough — markers `0-800` and `400-1200` sum to exactly a declared 1600 while `1200-1600` is uncovered. So finalise sorts the markers, merges overlapping AND adjacent spans, and requires exactly one span equal to `[0, expectedSize)`. A gap is a REAL error and the session is PRESERVED so the client can complete it, never a silent publish; the message names the first gap. A segment that ends early is not an error — its marker records the RECEIVED length only, so the union stays exact and re-sends may overlap freely (re-sending a covered range is idempotent). Two further finalise guards: a data file SHORTER than the declared size under a full marker set refuses the publish (marker/bytes desync — the one shape that would still publish zeros), and one LONGER is truncated back (unreachable through the API, so it means tampering). **`finalize` is IDEMPOTENT with a heal path**: if the final path already exists it skips assembly, (re)writes the row and cleans the session — so a finalise that published the bytes and then failed on the row is completed by simply retrying it. Bytes are published BEFORE the row deliberately (the inverse manufactures rows-without-files, which cas's verify classifies as loss evidence and never auto-fixes); the residue here is the benign kind — `get()` falls through to the file path when no row exists. **Durability: `fsync` defaults TRUE, and only the DATA fsync is load-bearing** — per segment the order is positional write -> `fsync(data)` -> create marker, so a surviving marker implies durable bytes; the marker's own durability is deliberately NOT guaranteed, since a lost marker beside present bytes costs a re-send, which is the safe direction. Measured: a process SIGKILLed after an un-fsynced write still yields the full bytes to a later reader (the page cache carries them), so markers stay honest across PROCESS death without fsync — only power loss or a kernel panic opens the window. The cost is a RANGE, not one flattering figure: ~12ms per 8MB segment is ~2% of network-paced ingest at 100 Mbps, ~19% at 1 Gbps, and fsync-DOMINANT at 10 Gbps, so a LAN-ingest consumer sets `fsync:false` and accepts the documented window. The parent-directory flush is best-effort (Windows and some network mounts cannot), and macOS honours fsync as the platform defines it — the `F_FULLFSYNC` caveat cas already documents. **Abandoned sessions get their OWN age-gated sweep** on two triggers — at driver BUILD (what reclaims whatever a crashed process left behind) and on an unref'd `setInterval` — because sessions legitimately live for DAYS where a crashed put()'s temp is dead immediately. A REFUSED put() (over `maxObjectSize`, or a source that errors) does not rely on that sweep at all: its cleanup waits for the write stream's `'close'` and the CALLBACK WAITS WITH IT, so a caller told the put failed can never observe residue (#B358). Do not "simplify" that back to a synchronous `existsSync`-gated unlink — `fs.createWriteStream()` opens ASYNCHRONOUSLY, so a refusal raised from the `'data'` handler runs while the `open(2)` is still in the threadpool: `existsSync` reads false, the unlink is skipped, and the worker thread creates the file a tick later, with NO event-loop turn in between for a synchronous check to catch it (measured ~30% orphan rate before the fix). **ALL THREE local strategies carry this fix** — `sharded` and `cas` build their write stream lazily inside `spill()`, so their window opens on the OTHER arm (a source error arriving just after the spill, rather than a `maxObjectSize` refusal), which is exactly how a first pass that measured only the refusal arm wrongly concluded they were immune. Fault-injecting the single false `existsSync` answer orphans a real temp on both. Two arms reach this handler — the `'data'` refusal and `stream.on('error')` — and a measurement covering one licenses no claim about the other. `sessionTtl` default `'24h'`, `sessionSweepInterval` default `'1h'`, both `parseDuration` (unit required); `'0s'` disables the interval only, and a ZERO ttl snaps back to the default rather than meaning "off", since it would reclaim in-flight uploads. Liveness is `mtime(data)`, NOT the session directory's — `data` advances on every segment write while a directory mtime only moves when entries are added or removed — with a dir-mtime fallback so a session whose data was renamed away by a half-finished finalise stays reclaimable. The same pass reclaims `<root>/.tmp` orphans from crashed `put()`s at the same cutoff (sharded has that gap and no such sweep). **No tiering, no hashing, by design**: `inlineThreshold` and `hash` are NOT in `STRATEGY_KEYS.stream`, so setting either warns as an ignored key rather than silently doing nothing, and `capabilities` reads `{offload:false, ranges:true, dedup:false, resumable:true, inline:false}`. `release()` unlinks the object and then rmdirs the asset directory, which succeeds only while it is empty — a future rendition beside `original` correctly keeps it. **Not in this release, each still deferred or capability-gated**: a rendition API; async post-hoc checksums; an `s3` adapter; and any `storage:gc`/`storage:verify`/stats extension for stream — those stay cas-only, so a stream driver has NO `sweepNow`/`verify` and is named-and-skipped by the maintenance surface rather than erroring. Multi-process is DOCUMENTED, not enforced: parallel segment writes are safe when ranges are DISJOINT (measured — two uncoordinated processes interleaving 1MB stripes into one shared file produced byte-exact output on a POSIX-coherent local filesystem), but relaxed-coherence network mounts are UNMEASURED, so route one session's segments to one process there. Shipping `stream` also EMPTIED `DEFERRED_STRATEGIES` — the list and its boot branch stay (cheap, and the next designed-before-implemented name inherits a working message) but are now exercised by nothing, which is recorded rather than hidden.
1010
1010
  303. HTTP Range serving for stored objects — `self.serveFromStorage(driverName, key[, opts])` is the read-side companion of `self.store()`'s driver routing and the consumer `capabilities.ranges` was waiting for: a controller action serves a stored object with the whole HTTP protocol dance owned by the framework, identically on both engines (headers and status ride the response object, bytes ride `renderStream`'s two arms — no `/_gina` endpoint involved). The flow is `stat()`-gated: an unknown or released key answers 404 through `throwError` (cas hides zero-ref rows from `stat`, so a released blob 404s by construction; on `stream` a finalize-heal residue whose metadata row write failed is readable at the driver but 404s here until the idempotent finalize heals the row), and a missing/unconfigured DRIVER is an app config error — 500, never 404. Validators are minted from the layer's own invariants: `ETag: "<key>"` is deliberately STRONG because storage keys are immutable (every strategy publishes via temp+rename, cas is content-addressed, and no in-place mutation API exists), plus `Last-Modified` from the publish time; `If-None-Match` is weak-compared against that ETag (a `W/`-prefixed echo matches) and answers 304 with no driver read. Range evaluation is GET-only and capability-gated (`driver.capabilities.ranges` false ⇒ the header is ignored and no `Accept-Ranges` is advertised — the forward seam for offloading adapters): a single `bytes=a-b`/`a-`/`-n` that is satisfiable answers 206 with `Content-Range: bytes a-b/<size>` and an exact `Content-Length`; an unsatisfiable one (start at or past the size, a `-0` suffix, any range against an empty object) answers 416 with `Content-Range: bytes */<size>` and an empty body; multi-range lists, foreign units, syntactic garbage and `a-b` with a>b are IGNORED into the full 200, which RFC 9110 sanctions; `If-Range` honours the Range only on an exact validator match, so anything unevaluable degrades fail-safe to the full body rather than risking a corrupted client-side assembly. Trust model, fail-closed: the stored contentType is UPLOADER-supplied verbatim, so without `opts.contentType` the active-content types (html/xml/svg/javascript/ecmascript) downgrade to `application/octet-stream` — nosniff cannot stop a DECLARED `text/html` from rendering — and every facade response carries `X-Content-Type-Options: nosniff`; an explicit `opts.contentType` is the app's informed choice and serves verbatim. Caching defaults to `Cache-Control: private, max-age=31536000, immutable` (correct because a key's bytes can never change; `private` keeps shared caches out of the application's authorization), with `opts.cacheControl` winning verbatim; `opts.download`/`opts.filename` emit an RFC 6266 attachment disposition with control characters stripped from the uploader-supplied name (header-injection guard — Node's setHeader would otherwise THROW on a CR/LF-bearing stored originalName, inside an async callback). HEAD answers headers-only with full-size accounting and no driver read. Headers are applied only in the driver-success callback, so a read error routes through `throwError` with no Range headers leaked; a post-stat race (cas grace expiry mid-download, a vanished file) maps the read verbs' `STORAGE_NO_OBJECT` code to 404 and anything else to 500 — the machine codes (`STORAGE_NO_OBJECT`/`STORAGE_RANGE_UNSATISFIABLE`/`STORAGE_INVALID_RANGE`, minted by `util.codedError` with message wording byte-unchanged) shipped for exactly this discrimination. The enablers landed in the same release on `renderStream`: non-SSE Buffer chunks pass through byte-exact (the historical unconditional utf8 decode substituted U+FFFD into binary payloads, which made the delegate unusable for byte serving; valid-UTF-8 re-encodes identically and SSE keeps its decode), HEAD parity (headers-only, the iterable never consumed, a destroyable source destroy()ed), caller-set headers surviving the delegate defaults (the h2 frame literal used to beat the pending merge and the h1 setHeader clobbered — a serving caller's Cache-Control/Content-Range now ride), and the post-end `headersSent` bookkeeping guarded (getter-only on both real response classes under the file's strict mode — the swallowed throw had killed the Inspector Flow stream-write/total timeline entries on every streamed request). Deliberately NOT here: multipart/byteranges (multi-range ⇒ full 200), Range on statics (the pure `_parseRangeHeader(header, size)` helper is reusable when that arc comes), and Range on non-GET methods. Offload serving SHIPPED with the s3 adapter (#304): `capabilities.offload` ⇒ GET/HEAD answer 307 to a presigned URL after the local 304 check, and the provider serves Range natively. Tests: `test/core/serve-from-storage.test.js` (parser matrix + fail-closed contentType matrix as extract-and-execute of the shipped bytes; the protocol arms driven end-to-end on REAL drivers across sharded/cas/stream with byte-exact 200/206 assertions) + `test/core/render-stream.test.js` §13-§16 + `test/lib/storage-error-codes.test.js`.
1011
1011
  304. **s3 storage adapter (#STO1 — the layer's last step)** — the second ADAPTER: `adapter:'s3'` + `bucket` (+ optional `region`/`endpoint` (https:// prepended when scheme-less; region defaults `us-east-1` ONLY beside a custom endpoint — real AWS keeps the SDK's own chain, whose missing-region message beats a mis-signed default)/`prefix` (trailing `/` normalised; the OPAQUE key EXCLUDES it, so re-pointing `prefix` orphans stored objects exactly as re-pointing a local `root` does)/`forcePathStyle` (MinIO)/`presignExpiry` ('15m')/`sweepGrace` ('1h')/`maxObjectSize`/static credentials). Dispatch became adapter × strategy by COMPOSITION — `ADAPTER_FACTORIES = {local: FACTORIES, s3: {sharded}}` — so the flat `_FACTORIES` stays the local row (the `storage:*` CLI dispatches through it offline and source-pins it; all three CLI verbs name s3 drivers remote and SKIP them BEFORE any build, ahead of the store skip so a warn-ignored `store` key cannot mislead the reason). s3 runs `sharded` ONLY — key naming; the provider owns placement — and refuses `cas`/`stream` at boot naming why (S3 ETags are not content digests; multipart is the provider's own resumable); `strategy` may be omitted (defaults sharded). STORELESS: `store=null`, no embedded `.meta.db`, no `.driver` stamp — one PutObject atomically carries bytes + ContentType (verbatim) + `x-amz-meta-gina-name` = encodeURIComponent(originalName) capped ~1KB (URI-encoded OURSELVES — AWS's RFC 2047 handling of non-ASCII metadata values is AWS-specific, plain ASCII round-trips on every compatible; AWS-doc-measured: user metadata 2KB total, keys lowercased, IMMUTABLE after upload, matching the layer's immutable keys). `stat()` = HeadObject (strongly consistent per AWS docs; ⚠️ a missing key answers 404 only WITH `s3:ListBucket`, else 403 — the guide ships the minimal policy: GetObject/PutObject/DeleteObject/ListBucket/ListBucketMultipartUploads/AbortMultipartUpload, bucket/prefix-scoped). `release()` = DeleteObject (idempotent acknowledgement — S3 reports no prior existence, so `existed:true` is DOCUMENTED as ack-only). `put()` streams through lib-storage's `Upload` (unknown length ⇒ multipart under the hood) behind a counting transform enforcing `maxObjectSize` MID-STREAM (breach ⇒ best-effort `Upload.abort()` + the REAL cap error; reported size = the meter's count — the bytes the provider acknowledged). The crashed-put analog is an INCOMPLETE MULTIPART UPLOAD (bills storage until aborted, no expiry — AWS-doc-measured): a build-time age-gated sweep (ListMultipartUploads → AbortMultipartUpload past `sweepGrace`, one bounded page per boot, best-effort never-throw — the sharded temp-orphan shape) reclaims them; a bucket `AbortIncompleteMultipartUpload` lifecycle rule stays recommended defense-in-depth. `resolve(key[, opts], fn)` answers the reserved `{kind:'url', url, expiresAt}` — a presigned GetObject; presigning is LOCAL SigV4 computation, so existence is deliberately NOT verified (the serving facade is stat-gated; a direct caller's URL to a vanished object answers the provider's own 404 — documented, not hidden). The NEW optional middle `opts` ({contentType, download, filename, cacheControl}) rides the SIGNED `response-content-type`/`-content-disposition`/`-cache-control` overrides (signed-request-only per AWS — a presigned URL qualifies) and landed on the CONTRACT for all strategies: locals accept and IGNORE it (zero prior resolve() callers, measured), so one facade call site serves every driver. `capabilities` = `{offload:true` (the layer's FIRST true — and its first reader ships beside it), `ranges:true` (get/getRange are REAL GetObject(+Range) proxies: provider InvalidRange/416 → STORAGE_RANGE_UNSATISFIABLE, NoSuchKey/NotFound → STORAGE_NO_OBJECT, anything else RAW-forwarded — the typed class is the signal), `dedup:false, resumable:false, inline:false}`. `serveFromStorage()` offload path: after the stat gate AND the local If-None-Match 304 (key ETag — no presign spent), GET/HEAD → **307** (method-preserving — HEAD stays HEAD) + Location + `Cache-Control: private, no-store` on the REDIRECT (it must never outlive its signature) while the PAYLOAD's policy (default `private, max-age=31536000, immutable`; `opts.cacheControl` wins) rides `response-cache-control`; the facade runs its fail-closed contentType DOWNGRADE BEFORE presigning and hands resolve() the SAFE type — the stored-XSS guard holds when the provider serves the bytes; `opts.offload:false` forces the in-process proxy path; an offload-claiming driver whose resolve answers no url is a LOUD 500 (driver contract violation), never a silent degrade; non-GET/HEAD methods always proxy. SDK = 3 PROJECT-side packages (`@aws-sdk/client-s3` + `@aws-sdk/lib-storage` + `@aws-sdk/s3-request-presigner`; v3 required — a module without S3Client is refused by name) resolved LAZILY through `start()`'s injected `requireProjectModule` (built in gna.js from `getPath('project')` — lib/storage is framework-independent, test-enforced, and cannot reach the globals; tests inject a fake SDK via the factory's 4th `deps` arg); a missing SDK on a CONFIGURED driver is boot-FATAL with the npm hint (the no-safe-degraded-mode rationale). `${secret:KEY}` works for the static credential pair TODAY (the whole-tree config walk); omit both credentials and the SDK default provider chain runs (env / shared config / IMDS / IRSA — the container-native path, first-class). S3-compat honesty: strong read-after-write consistency (HEAD + LIST included) and the metadata semantics are AWS-DOCUMENTED behaviour; a compatible provider owns its own — stated in the guide, and the adapter uses only the core compat surface (Put/Get/Head/Delete/ListObjectsV2/ListMultipartUploads/Abort + SigV4 presigning). Tests: `test/lib/storage-s3.test.js` (REAL adapter + fake SDK that THROWS on unimplemented commands, 60) + `test/core/serve-from-storage-offload.test.js` (17 — incl. the signed-downgrade security pin and 304-before-presign).
1012
- 305. **`self.push()` — the recipient is decided SERVER-SIDE, and reaching every client requires an explicit `{ broadcast: true }` (#B364, 0.6.8; live vulnerability in every release ≤ 0.6.7).** The engine.io push primitive used to read its recipient from the REQUEST BODY (`req[method].sessionID`), so any caller on a route that reached `push()` could aim a payload at another user's session by naming its id — and OMITTING the id fell open to a broadcast to every connected socket. The payload defaults to request input too, so both the target and the content were attacker-chosen. Resolution order is now `option.sessionID` (supplied by YOUR server-side code) → the caller's own session → nothing at all, with a warn; the request body cannot influence the recipient in any branch, and an all-clients fan-out must be asked for deliberately with `{ broadcast: true }`. **The migration this forces has NO in-process substitute today, and that is stated rather than papered over:** `push()` returns early once the request is released (`local.req == null`) and nothing outside the controller can reach the socket set (measured — zero other `eio.clients` readers across core and lib), so a BACKGROUND WORKER that reported progress by making an HTTP hop to a push route while carrying the queueing user's session id now resolves to the caller's own session and stops delivering. That hop IS the vulnerability, so it stays closed; until an explicit out-of-request channel exists (#B366, design-gated — an authenticated server-to-server channel written under release pressure is how the next version of this bug gets authored), out-of-request progress goes through polling `GET /_gina/jobs/:id` or a transport the application owns. Note a consumer whose auth layer ADOPTS a token's session id onto `req.session.id` before the controller runs keeps delivering, because the caller's-own-session branch then resolves to the target — so the breakage is real but not universal. Receiver-side identity is a SEPARATE, still-open axis: an engine.io socket asserts its own session id at handshake, so this governs who a SENDER may aim at, never how a RECEIVER proves who it is (#B365). Rule: a primitive whose blast radius is "everyone connected" must make the wide case the explicit one — a recipient that defaults to the request, and an absent recipient that means everybody, is a broadcast primitive any caller can aim.
1012
+ 305. **`self.push()` — the recipient is decided SERVER-SIDE, and reaching every client requires an explicit `{ broadcast: true }` (#B364, 0.6.8; live vulnerability in every release ≤ 0.6.7).** The engine.io push primitive used to read its recipient from the REQUEST BODY (`req[method].sessionID`), so any caller on a route that reached `push()` could aim a payload at another user's session by naming its id — and OMITTING the id fell open to a broadcast to every connected socket. The payload defaults to request input too, so both the target and the content were attacker-chosen. Resolution order is now `option.sessionID` (supplied by YOUR server-side code) → the caller's own session → nothing at all, with a warn; the request body cannot influence the recipient in any branch, and an all-clients fan-out must be asked for deliberately with `{ broadcast: true }`. **The migration this forces is NARROWER than the release first stated, and the correction matters:** carrying the recipient IN THE REQUEST is the vulnerability, so a hop that threads the queueing user's session id through the body stays closed — but the HTTP-hop SHAPE is recoverable whenever that hop is AUTHENTICATED. `option.sessionID` is the FIRST branch of the resolution order, so a request-bound controller that decodes a credential its own server minted can name the recipient itself (`self.push(payload, { sessionID: verified.sessionID })`), leaving the recipient server-decided and the request body still unable to influence it. ⚠️ `0.6.7` ACCEPTS `option` but never reads it for the recipient (verified at the tag — the recipient there is `req[method].sessionID` and nothing else), so an option-only migration is a SILENT no-op before `0.6.8` and a deployment spanning both versions must write BOTH sinks. What genuinely has NO substitute is a worker with no request context at all: `push()` returns early once the request is released (`local.req == null`) and nothing outside the controller can reach the socket set (measured — zero other `eio.clients` readers across core and lib), so that shape goes through polling `GET /_gina/jobs/:id` or a transport the application owns until an explicit out-of-request channel exists — **which now DOES: `gina.pushToSession(sessionID, payload[, option][, callback])` (#B366).** It is deliberately narrow, because the failure it replaces was a wide default: the recipient is a REQUIRED argument (absent/empty is an ERROR, never a fan-out) and **no broadcast is reachable from that API at all** — the deliberate all-clients send stays in-request as `self.push(payload, { broadcast: true })`. Source the recipient from SERVER-HELD state captured when the work was queued; round-tripping a recipient id — or a token naming one — through the browser hands the choice back to the caller and re-opens this bug one layer up. Delivery is REPORTED, not assumed: `callback(err, {delivered})` fires exactly once with the socket count and `delivered: 0` is a normal outcome, because the shape it replaces was fire-and-forget and blind (a consumer measured ~41% of their progress pushes never sending, with nothing surfacing it). Coded errors: `PUSH_INVALID_RECIPIENT` / `PUSH_INVALID_PAYLOAD` / `PUSH_CHANNEL_NOT_CONFIGURED` (no engine.io — includes the express engine) / `PUSH_CHANNEL_NOT_READY` (before the bundle's first request publishes the instance) / `PUSH_PAYLOAD_SERIALIZE_FAILED`. Also `lib.push.toSession(instance, …)` for code holding its own instance. ⚠️ **The IN-REQUEST `self.push()` now refuses the same way on a channel-less engine (#B371).** The channel is an isaac-engine facility — `serverInstance.eio` is attached there and nowhere else — so on the express engine the client lookup used to dereference `undefined` and surface as an opaque TypeError-derived 500 naming neither push nor the channel. It now warns, hands a supplied `callback` a `PUSH_CHANNEL_NOT_CONFIGURED` error — fulfilling the `callback(err, result)` contract its JSDoc had always documented but the body never once invoked — and sends nothing, WITHOUT failing the request: the condition is structural and process-wide, identical on every call, so a notification side-channel does not take down the response it rode in on. ⚠️ **The obvious escape hatch does NOT work, and fails SILENTLY — measured.** `push()` resolves the caller's session from `req.sessionID` FIRST and only then falls back to `req.session.id`, so an app that assigns **`req.sessionID`** (a plain request property) to the target before the controller runs does keep delivering — but assigning **`req.session.id`** does not: `express-session` defines it as `Object.defineProperty(this, 'id', { value: req.sessionID })` with no `writable` (verified first-hand at `session/session.js:26`, v1.17.3), so outside strict mode the write is a no-op, no error is raised, and push() resolves to a freshly minted per-request id unrelated to the target. This correction is itself a worked example of the rule: a consumer relayed "it still delivers" as MEASURED having only verified the assignment LINE EXISTED, never that it EXECUTED — it sat behind a branch that could not be reached, and would have been inert anyway. Verifying a citation exists is not verifying it runs. Receiver-side identity is the SEPARATE second axis, and it is now CLOSED too (#B365): a socket's `sessionId` used to come from `payload.session.id` — client-sent, on EVERY message, never checked — so anyone could claim another user's session and receive its pushes, needing strictly LESS than the cookie because a rendered page carries the BARE sid in its bootstrap script while the cookie carries the signed form. It is now derived once at connection from the upgrade request's own cookie, by replaying the bundle's OWN session middleware over that request (same secret/store/cookie name, zero new config, and the framework never handles the secret — it works whether or not the bundle adopted `gina.plugins.Session()`), with an INERT response so a socket upgrade can never emit `Set-Cookie` or persist a session. Fails CLOSED: no middleware / no cookie / no verify ⇒ no id ⇒ the socket matches no targeted push and receives only deliberate broadcasts. A client that still asserts an id is logged and ignored — a free impersonation detector. Rule of thumb the pair encodes: decide the SENDER's aim and the RECEIVER's identity separately, and never let either default to a client-supplied value. Rule: a primitive whose blast radius is "everyone connected" must make the wide case the explicit one — a recipient that defaults to the request, and an absent recipient that means everybody, is a broadcast primitive any caller can aim.
1013
+
1014
+ 306. **Per-scope bundle deployment — `manifest.json` `bundles[<name>].scopes` (#B373, unreleased).** A registered bundle used to be deployed in EVERY scope: the only exclusion was leaving it out of `manifest.bundles`, which removes it from all of them. `scopes` is an OPTIONAL allow-list with the same semantics as a route's rule-level `scopes` — **absent or null means every scope** (so every existing manifest is unchanged), `["local"]` means that scope only, `[]` parks the bundle everywhere, and a NON-ARRAY is a named manifest error rather than a silent "no scopes" (the predicate returns false for both, so reporting a type error as "not deployed" would send the operator hunting the wrong thing). ⚠️ **Deleting `releases[<scope>]` by hand is NOT a substitute and never was** — measured: BOTH build verbs walk every project scope and re-seed any missing `releases[scope]` + `target`, so a hand-deleted entry regrows on the next build even one built for a different scope; absence cannot carry intent, which is why this is a key and not a convention. A boolean inside `releases[<scope>]` was also refused: `bundle:copy` and `bundle:rename` both `for (var env in entry.releases[scope])`, so it would be iterated as an environment. **Skip vs refuse is split by whether the exclusion was ASKED FOR:** the boot loop and `project:build` (bulk) SKIP an excluded bundle with a notice naming the scope and the remedy — one parked bundle must not block a project build, and a bundle vanishing silently is the #B183 lesson; the STARTING bundle and `bundle:build <name> --scope=X` (an explicit single target) REFUSE by name, because a silent no-op on a deploy script reads as success. The starting-bundle refusal is deliberately placed BEFORE the apps loop: skipping it instead would kill the boot further down in the #B181(b) env.json guard, whose message describes an entirely different cause. The predicate is DUPLICATED in `core/gna.js` because mount resolution runs before Config exists and cannot reach that closure — a test pins the two copies as behaviourally identical, since drift would mean a bundle skipped at one layer and mounted at the other. Both gna sites mattered: the CLI mount deref sits OUTSIDE any try (an excluded starting bundle died there as an opaque TypeError before Config's named refusal could run), and the mount-resolution loop's deref sits in a try whose catch ABORTS the whole mount (so one excluded sibling would have taken the process down at any non-dev scope). Seeding is filtered in both build verbs, which is what makes the opt-out durable. Documented in `schema/manifest.json` (optional, never required) and its docs-site copy. Rule the shape encodes: when a config key already exists whose ABSENCE you are tempted to overload as intent, check who WRITES it — a value your own tooling re-creates cannot express a decision. **The declaration also survives `project:add`/`project:import` (#B375 + #B376, fixed unreleased in the same release as this feature).** Those verbs used to carry a 2023-era count-mismatch reset: whenever a project's DECLARED bundle count differed from a readdir of `<project>/src` (else `/releases`), the whole `manifest.bundles` block was emptied and written back — for EVERY registered project, not just the target — then only the import target was rebuilt from a six-key template carrying no `scopes`, no `gina_version` and no custom keys, with `version`/`tag`/release targets reset to defaults. A bystander project got NO rebuild (a permanently empty `bundles` block that fails its next boot), and plain `project:add` never rebuilds at all. The reset is retired (kept commented in `lib/cmd/helper.js` per the replace-don't-delete convention) and the end-of-loadAssets rescan now runs as a UNION for the current project: manifest-declared bundles keep their entries verbatim, disk-only bundles are appended for the additive #B55 seeding, and a declared bundle whose tree is absent is WARNED about once per command (naming bundle + scanned location) — never auto-pruned, because the declaration may be precisely this entry's scopes restriction; removal stays `gina bundle:remove`'s job. The rescan also derives each bundle's `configPaths.settings` from the loop-invariant scan root (#B376 — the old loop grew the root by one segment per iteration, so every bundle after the first pointed into its predecessors' tree and the protocol/scheme settings-consistency pass silently skipped it), and `addBundlePorts` skips a declared-but-absent bundle's settings check instead of dereferencing its missing `configPaths` (a latent TypeError reachable via a manifest declaring N bundles of which some are swapped for different on-disk names). Tests: `test/lib/project-manifest-resync.test.js` (17 — source pins + union/subtract replicas, red-first validated 11-fail/6-pass against the pre-fix source). **Registration is also protocol/scheme-hygienic (#B378 + #B379 + #B380, fixed unreleased in the same release):** a project's `protocols`/`schemes` lists in `~/.gina/projects.json` are DERIVED bookkeeping constrained to the framework's supported sets in `~/main.json` — they may gain a value a bundle's `settings.json` validly declares (that is how the port matrix learns what a project's bundles use), but an unsupported value is warned about by name (bundle, value, allowed set) once per command and adopted nowhere, not even as the bundle entry's default. Previously ANY string a bundle declared was adopted and persisted, and `image:build` baked the polluted list into the synthesized container's environment (`ENV GINA_PROTOCOLS=…`); worse, the enrichment pass resolved EVERY registered project's bundles against the CURRENT project's path, so two projects sharing a bundle name (an `api`/`web` in both) leaked declarations into each other's registry entries from commands that never named them — each project's bundles now resolve against its own path. The import-time heal that rewrites an invalid bundle declaration to the project default still runs (it keeps the bundle bootable) but reports each change by name as a warning instead of a debug line, and the port-setup merge reads its source list by its own index (it read the target's, pushing `undefined` on overshoot — no demonstrated consequence, fixed for correctness) and admits only framework-supported values, so stale `ports.json` keys from a framework update cannot re-enter the lists. The runtime boot path never reads the project lists (measured — `core/gna.js` zero references, `core/config.js` one comment; `core/server.js`'s read is a dead variable), so pickup is CLI-side only. Tests: `test/lib/project-protocol-hygiene.test.js` (19 — pins + adoption/merge replicas with subtract arms, red-first 12-fail/7-pass against the pre-fix source).
1015
+ 307. **Express engine — Express 5 supported, range declared `>= 4 < 6` (#B211, unreleased).** A bundle with `settings.server.json > engine: "express"` could not boot on Express 5, twice over: (1) the engine-agnostic catch-all mounted `all('*')`, and Express 5's router (path-to-regexp v8) REJECTS the bare-string wildcard at mount time — `TypeError: Missing parameter name`, exit before `listen()`; (2) past the mount, Express 5 defines `req.query` as a prototype GETTER (v4 assigned it from its auto-mounted query middleware as an own property), and the framework's request pipeline ASSIGNS `request.query` at 8 sites under `'use strict'` — each a per-request `TypeError: Cannot set property query ... which has only a getter`. Fixes: the catch-all pattern is ENGINE-CONDITIONAL — express mounts the RegExp `/(.*)/ ` (accepted + live-dispatching `/`, webroot and deep paths on Express 4 AND 5), while isaac KEEPS the string `'*'` because its request listener's dispatch gate is literally `path === '*' || path == request.url` (server.isaac.js) — a RegExp there matches nothing and every request HANGS with no response (measured; the gate is pinned by `test/core/express-engine-compat.test.js` as a tripwire). The express adapter (`core/server.express.js`) shadows `query` on the per-app request prototype with a writable own DATA property — a measured no-op on Express 4, and on 5 it restores the framework's owns-the-query-parse contract. The adapter logs the detected express version at engine construction (`engine: express v<X> (supported: >= 4 < 6)`), and an out-of-range major (`< 4` or `>= 6`) logs a LOUD warning but still boots — warn-not-refuse by design decision (a wrong refusal is a total outage; a wrong warning is a log line). Express stays CONSUMER-PROVIDED: deliberately not a framework dependency, and NEVER a peerDependency — npm >= 7 auto-installs peers, which would force express onto every consumer; install `express@^5` (or `@^4`) in the project itself. Server-side only ⇒ pickup = bundle restart, NO re-bake. Verified by a live A/B boot smoke on express 4.22.2 AND 5.2.1 (same scaffold, both serve `/web/` + `/_gina/health/check` + `/_gina/info` 200 and complete a POST through the query-assign sites) plus 13 unit pins incl. a pure-node replica of the getter mechanism.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gina",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "MVC framework for Node.js and Bun with built-in HTTP/2, multi-bundle architecture, and scope-based data isolation — no Express dependency",
5
5
  "keywords": [
6
6
  "nodejs",
@@ -76,12 +76,12 @@
76
76
  "gina-container": "bin/gina-container",
77
77
  "gina-init": "bin/gina-init"
78
78
  },
79
- "main": "./framework/v0.6.8/core/gna",
79
+ "main": "./framework/v0.6.9/core/gna",
80
80
  "exports": {
81
81
  ".": {
82
82
  "types": "./types/index.d.ts",
83
83
  "import": "./index.mjs",
84
- "require": "./framework/v0.6.8/core/gna.js"
84
+ "require": "./framework/v0.6.9/core/gna.js"
85
85
  },
86
86
  "./gna": {
87
87
  "types": "./types/gna.d.ts",
@@ -75,6 +75,12 @@
75
75
  "description": "Path to the bundle symlink under the project bundles directory, relative to the project root.",
76
76
  "examples": ["bundles/api", "bundles/frontend"]
77
77
  },
78
+ "scopes": {
79
+ "type": "array",
80
+ "description": "Optional allow-list of scopes this bundle is deployed in. ABSENT (or null) means every scope — the default, and the historical behaviour. An empty array parks the bundle: it is built and booted in no scope at all. Same semantics as a route's rule-level `scopes`. A bundle excluded from the scope being booted is skipped with a notice; starting it, or explicitly building it, in an excluded scope is refused by name.",
81
+ "items": { "type": "string" },
82
+ "examples": [["local"], ["local", "staging"], []]
83
+ },
78
84
  "releases": {
79
85
  "type": "object",
80
86
  "description": "Release build paths, keyed by scope then environment.",