gina 0.6.5 → 0.6.6
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.
- package/CHANGELOG.md +10 -0
- package/README.md +23 -36
- package/ROADMAP.md +1 -0
- package/framework/v0.6.6/VERSION +1 -0
- package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/js/gina.js +84 -25
- package/framework/v0.6.6/core/asset/plugin/dist/vendor/gina/js/gina.min.js +643 -0
- package/framework/v0.6.6/core/asset/plugin/dist/vendor/gina/js/gina.min.js.br +0 -0
- package/framework/v0.6.6/core/asset/plugin/dist/vendor/gina/js/gina.min.js.gz +0 -0
- package/framework/{v0.6.5 → v0.6.6}/lib/logger/src/containers/file/index.js +9 -5
- package/framework/v0.6.6/lib/logger/src/containers/mq/speaker.js +439 -0
- package/framework/{v0.6.5 → v0.6.6}/lib/net-locality/src/main.js +48 -2
- package/framework/{v0.6.5 → v0.6.6}/package.json +1 -1
- package/gna.js +4 -4
- package/llms.txt +30 -30
- package/package.json +3 -3
- package/framework/v0.6.5/VERSION +0 -1
- package/framework/v0.6.5/core/asset/plugin/dist/vendor/gina/js/gina.min.js +0 -643
- package/framework/v0.6.5/core/asset/plugin/dist/vendor/gina/js/gina.min.js.br +0 -0
- package/framework/v0.6.5/core/asset/plugin/dist/vendor/gina/js/gina.min.js.gz +0 -0
- package/framework/v0.6.5/lib/logger/src/containers/mq/speaker.js +0 -242
- /package/framework/{v0.6.5 → v0.6.6}/AUTHORS +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/LICENSE +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/html/nolayout.html +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/html/static.html +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/img/android-chrome-192x192.png +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/img/android-chrome-512x512.png +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/img/apple-touch-icon.png +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/img/favicon-16x16.png +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/img/favicon-32x32.png +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/img/favicon.ico +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/beemaster/beemaster.css +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/beemaster/beemaster.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/beemaster/index.html +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/css/gina.min.css +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/css/gina.min.css.br +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/css/gina.min.css.gz +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/html/statusbar.html +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/html/statusbar.html.br +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/html/statusbar.html.gz +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/inspector/have_heart_one-webfont.woff2 +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/inspector/index.html +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/inspector/inspector.css +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/inspector/inspector.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/inspector/logo.svg +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/js/gina.onload.min.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/js/gina.onload.min.js.br +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/asset/plugin/dist/vendor/gina/js/gina.onload.min.js.gz +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/config.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/ai/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/ai/lib/connector.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/couchbase/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/couchbase/lib/connector.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/couchbase/lib/connector.v3.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/couchbase/lib/connector.v4.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/couchbase/lib/n1ql.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/couchbase/lib/session-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/couchbase/lib/session-store.v3.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/couchbase/lib/session-store.v4.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/duckdb/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/duckdb/lib/connector.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/mongodb/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/mongodb/lib/connector.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/mongodb/lib/job-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/mongodb/lib/pipeline-loader.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/mongodb/lib/session-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/mysql/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/mysql/lib/connector.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/postgresql/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/postgresql/lib/connector.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/redis/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/redis/lib/connector.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/redis/lib/job-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/redis/lib/render-cache-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/redis/lib/session-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/scylladb/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/scylladb/lib/connector.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/scylladb/lib/session-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/sql-parser.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/sqlite/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/sqlite/lib/connector.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/sqlite/lib/job-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/connectors/sqlite/lib/session-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/content.encoding +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/controller/controller.framework.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/controller/controller.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/controller/controller.render-json.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/controller/controller.render-nunjucks-async.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/controller/controller.render-nunjucks.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/controller/controller.render-stream.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/controller/controller.render-swig-async.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/controller/controller.render-swig.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/controller/controller.render-v1.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/controller/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/controller/inspector-window-emit.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/controller/release-banner.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/dev/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/dev/lib/class.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/dev/lib/factory.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/dev/lib/tools.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/gna.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/locales/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/locales/currency.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/locales/dist/language/en.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/locales/dist/language/fr.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/locales/dist/region/en.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/locales/dist/region/fr.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/locales/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/mime.types +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/model/entity.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/model/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/model/template/entityFactory.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/model/template/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/csrf/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/csrf/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/csrf/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/coep/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/coep/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/coep/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/coop/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/coop/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/coop/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/corp/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/corp/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/corp/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/csp/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/csp/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/csp/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/hide-powered-by/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/hide-powered-by/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/hide-powered-by/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/hsts/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/hsts/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/hsts/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/origin-agent-cluster/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/origin-agent-cluster/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/origin-agent-cluster/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/referrer-policy/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/referrer-policy/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/referrer-policy/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-content-type-options/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-content-type-options/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-content-type-options/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-dns-prefetch-control/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-dns-prefetch-control/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-dns-prefetch-control/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-download-options/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-download-options/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-download-options/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-frame-options/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-frame-options/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-frame-options/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-permitted-cross-domain-policies/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-permitted-cross-domain-policies/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-permitted-cross-domain-policies/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-xss-protection/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-xss-protection/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/security-headers/x-xss-protection/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/session/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/session/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/session/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/storage/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/storage/build.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/storage/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/storage/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/validator/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/validator/build.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/validator/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/validator/src/form-validator.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/plugins/lib/validator/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/router.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/server.express.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/server.isaac.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/server.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/status.codes +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/_gitignore +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle/config/app.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle/config/connectors.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle/config/routing.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle/config/settings.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle/config/settings.server.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle/config/templates.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle/config/watchers.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle/controllers/controller.content.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle/controllers/controller.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle/controllers/setup.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle/locales/en.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_namespace/controllers/controller.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_public/css/default.css +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_public/css/home.css +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_public/css/vendor/readme.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_public/favicon.ico +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_public/js/components/x-checklist.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_public/js/vendor/readme.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_public/manifest.webmanifest +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_public/readme.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_public/sw.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_templates/handlers/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_templates/html/content/homepage.html +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_templates/html/includes/error-msg-noscript.html +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_templates/html/includes/error-msg-outdated-browser.html +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_templates/html/includes/x-checklist.html +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/boilerplate/bundle_templates/html/layouts/main.html +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/command/gina.bat.tpl +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/command/gina.tpl +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/conf/env.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/conf/manifest.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/conf/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/conf/settings.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/conf/statics.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/conf/templates.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/error/client/json/401.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/error/client/json/403.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/error/client/json/404.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/error/server/html/50x.html +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/error/server/json/500.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/error/server/json/503.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/core/template/extensions/logger/config.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/console.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/context.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/data/LICENSE +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/data/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/data/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/data/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/dateFormat.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/json/LICENSE +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/json/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/json/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/json/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/path.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/plugins/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/plugins/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/plugins/src/api-error.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/plugins/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/prototypes.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/task.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/helpers/text.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/admin/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/admin/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/archiver/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/archiver/build.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/archiver/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/archiver/src/dep/jszip.min.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/archiver/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/async/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/async/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/audit/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/audit/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/audit-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/authn/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/authn/src/lockout.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/authn/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/authn/src/totp.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/authz-gate/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/authz-gate/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cache/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cache/build.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cache/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cache/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/aliases.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/audit/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/audit/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/audit/verify.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/add.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/build.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/copy.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/cp.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/inc/name-rewrite.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/list.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/man.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/mcp-start.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/mcp.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/oas.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/openapi.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/remove.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/rename.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/restart.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/rm.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/start.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/status.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/stop.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/bundle/types.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/cache/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/cache/clear.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/cache/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/cache/stats.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/connector/add.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/connector/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/connector/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/connector/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/connector/infer.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/connector/list.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/connector/migrate.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/connector/models.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/connector/remove.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/connector/rm.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/connector/test.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/container/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/container/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/container/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/container/man.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/container/ps.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/container/stop.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/controller/add.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/controller/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/controller/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/controller/inc/args.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/controller/inc/namespace.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/controller/inc/reference-rewrite.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/controller/inc/reference-scan.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/controller/inc/scaffold.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/controller/remove.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/controller/rename.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/controller/rm.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/env/add.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/env/get.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/env/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/env/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/env/link-dev.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/env/list.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/env/remove.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/env/rm.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/env/set.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/env/unset.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/env/use.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/add.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/build.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/dot.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/get.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/init.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/link-node-modules.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/link.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/list.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/man.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/msg.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/open.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/remove.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/reset.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/restart.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/set.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/start.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/status.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/stop.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/tail.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/update.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/framework/version.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/gina-dev.1.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/gina-framework.1.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/gina.1.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/helper.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/i18n/add.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/i18n/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/i18n/export.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/i18n/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/i18n/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/i18n/import.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/i18n/scan.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/image/_host.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/image/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/image/build.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/image/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/image/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/image/list.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/image/man.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/image/rm.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/image/run.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/inspector/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/inspector/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/inspector/open.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/man-render.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/minion/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/minion/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/minion/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/minion/kill.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/minion/list.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/msg.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/port/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/port/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/port/inc/scan.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/port/list.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/port/reset.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/port/set.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/add.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/backup.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/build.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/import.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/list.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/man.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/move.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/remove.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/rename.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/restart.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/restore.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/rm.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/start.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/status.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/project/stop.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/protocol/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/protocol/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/protocol/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/protocol/list.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/protocol/remove.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/protocol/set.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/scope/add.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/scope/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/scope/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/scope/link-local.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/scope/link-production.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/scope/list.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/scope/remove.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/scope/rm.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/scope/use.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/secrets/arguments.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/secrets/check.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/secrets/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/secrets/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/secrets/scan.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/service/help.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/service/help.txt +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/service/list.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/service/man.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/service/start.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd/view/add.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd-status-format/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cmd-status-format/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/collection/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/collection/build.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/collection/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/collection/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/config.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/connector-config/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/connector-config/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/connector-error/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/connector-error/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/connector-registry/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/connector-registry/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cron/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cron/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/cron/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/domain/LICENSE +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/domain/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/domain/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/domain/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/dto/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/dto/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/dto-pipe/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/dto-pipe/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/dto-types/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/dto-types/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/generator/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/i18n/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/i18n/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/image-build/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/image-build/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/inherits/LICENSE +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/inherits/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/inherits/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/inherits/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/inspector-events/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/inspector-events/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/inspector-redact/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/inspector-redact/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/instrument/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/instrument/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/job/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/job/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/job-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/json-config-header/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/json-config-header/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/loading-state/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/loading-state/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/logger/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/logger/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/logger/src/containers/default/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/logger/src/containers/file/lib/logrotator/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/logger/src/containers/file/lib/logrotator/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/logger/src/containers/mq/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/logger/src/containers/mq/listener.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/logger/src/helper.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/logger/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/math/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/mcp-dispatch/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/mcp-dispatch/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/mcp-http/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/mcp-http/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/mcp-server/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/mcp-server/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/merge/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/merge/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/merge/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/metrics/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/metrics/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/model.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/net-locality/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/nunjucks-filters/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/nunjucks-filters/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/nunjucks-filters/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/nunjucks-resolver/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/nunjucks-resolver/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/proc.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/release-watch/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/release-watch/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/render-cache/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/render-cache/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/render-cache-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/routing/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/routing/build.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/routing/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/routing/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/routing/src/radix.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/routing-introspect/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/routing-introspect/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/secrets/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/secrets/src/backends/env.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/secrets/src/backends/file.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/secrets/src/env-file.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/secrets/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/session-store.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/shell.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/sqlite-driver.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/state.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/swig-filters/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/swig-filters/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/swig-filters/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/swig-resolver/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/swig-resolver/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/template-loaders/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/template-loaders/src/loaders/http.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/template-loaders/src/loaders/memory.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/template-loaders/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/url/README.md +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/url/index.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/url/routing.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/uuid/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/uuid/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/validator.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/watcher/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/watcher/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/ws-framing/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/ws-framing/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/ws-query/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/ws-query/src/main.js +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/ws-session/package.json +0 -0
- /package/framework/{v0.6.5 → v0.6.6}/lib/ws-session/src/main.js +0 -0
package/llms.txt
CHANGED
|
@@ -868,7 +868,7 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
868
868
|
- **Routing loop cross-route contamination** — `fitsWithRequirements()` mutates `request.params` and `request[method]` during each route comparison. Leftover values from non-matching routes cause `parseRouting` ~line 419 to inject phantom URL segments, compounding work exponentially. Fix: `req.params = Object.assign({}, _origParams)` + restore `req[method]` from `_origReqMethod` (`req[method] = Object.assign({}, _origReqMethod)`, `server.js:4976`) before each `compareUrls()` — NEVER `delete req[method]` (that destroys the parsed POST/PUT body) and never `delete req.params` — `fitsWithRequirements` gates on `typeof(request.params) != 'undefined'`. Never use `JSON.clone` on request state in the routing loop — `Object.assign` is sufficient and safe.
|
|
869
869
|
- **`server.isaac.js:1954` initializes `req.params`** — `request.params = {}` and `request.params[0] = url` are set before `handle()` runs. Any code that deletes `req.params` breaks `fitsWithRequirements` which checks `typeof(request.params) != 'undefined'` before setting param values.
|
|
870
870
|
- **internal-bundle `setting-get-one-design` was a latent crash** — an internal bundle's routing had `"url": "/settings/get/design/:designId"` with no `requirements` block. Before the `fitsWithRequirements` fix, every GET to that URL threw a 500 TypeError. The fix makes it work correctly. Worth testing that route after the next ff-only merge.
|
|
871
|
-
- **`negotiate: true` opts a route into content negotiation, and is mutually exclusive with the render cache (#SPA1, 0.6.x).** A route declaring the top-level boolean `negotiate` (sibling of `method`/`url`/`cache`, NOT under `param` — the #CSRF2 trap) answers a request carrying `X-Gina-Navigate: fragment` with its LAYOUTLESS body (the same path `renderWithoutLayout()` uses) instead of the full page; any other header value, or none, renders exactly as before, so the value vocabulary can grow without breaking clients. The signal is deliberately a gina-namespaced header rather than `X-Requested-With` — popin, link and validator all already send the latter and the render + error paths already fork on it, so overloading it would change their behaviour. A negotiable route ALWAYS emits `Vary: X-Gina-Navigate` (appended to any existing `Vary`, never replacing it — it is a list header and the CORS paths set `vary: Origin`), including on responses that did not themselves vary: that is how a shared cache learns the URL varies at all. **The cache exclusion is not a limitation to be optimised away later without work** — the render-cache key (`[<token>:]<kind>:<bundle>:<url>`) carries NO shape dimension, and BOTH serve points run before the shape is resolved, so a stored entry could replay a fragment to a page request. Enforcement is at the WRITERS (`render-swig` + `render-nunjucks` `writeCache`, the same guard shape as #B158's gated-route refusal) and that placement is LOAD-BEARING, not defence-in-depth: isaac's cache read runs PRE-ROUTING (keyed off the raw `request.url`, no `request.routing` in scope), so it can never be guarded on the flag — keeping the URL out of the cache entirely is what protects that engine. The express-side read guard is the belt. Resolution lives in `controller.js this.render` before the delegate is chosen — NOT in `completeHeaders`, which runs before the params block builds `req.routing` and would therefore never fire. Undeclared ⇒ byte-identical: no new header, no behaviour change, no cache impact. **The client consumer of the flag is the opt-in `gina/nav` navigation module (#SPA1, 0.6.
|
|
871
|
+
- **`negotiate: true` opts a route into content negotiation, and is mutually exclusive with the render cache (#SPA1, 0.6.x).** A route declaring the top-level boolean `negotiate` (sibling of `method`/`url`/`cache`, NOT under `param` — the #CSRF2 trap) answers a request carrying `X-Gina-Navigate: fragment` with its LAYOUTLESS body (the same path `renderWithoutLayout()` uses) instead of the full page; any other header value, or none, renders exactly as before, so the value vocabulary can grow without breaking clients. The signal is deliberately a gina-namespaced header rather than `X-Requested-With` — popin, link and validator all already send the latter and the render + error paths already fork on it, so overloading it would change their behaviour. A negotiable route ALWAYS emits `Vary: X-Gina-Navigate` (appended to any existing `Vary`, never replacing it — it is a list header and the CORS paths set `vary: Origin`), including on responses that did not themselves vary: that is how a shared cache learns the URL varies at all. **The cache exclusion is not a limitation to be optimised away later without work** — the render-cache key (`[<token>:]<kind>:<bundle>:<url>`) carries NO shape dimension, and BOTH serve points run before the shape is resolved, so a stored entry could replay a fragment to a page request. Enforcement is at the WRITERS (`render-swig` + `render-nunjucks` `writeCache`, the same guard shape as #B158's gated-route refusal) and that placement is LOAD-BEARING, not defence-in-depth: isaac's cache read runs PRE-ROUTING (keyed off the raw `request.url`, no `request.routing` in scope), so it can never be guarded on the flag — keeping the URL out of the cache entirely is what protects that engine. The express-side read guard is the belt. Resolution lives in `controller.js this.render` before the delegate is chosen — NOT in `completeHeaders`, which runs before the params block builds `req.routing` and would therefore never fire. Undeclared ⇒ byte-identical: no new header, no behaviour change, no cache impact. **The client consumer of the flag is the opt-in `gina/nav` navigation module (#SPA1, 0.6.3):** a page carrying a `data-gina-nav` element (the swap region AND the page's opt-in in ONE marker; the value `"false"` is reserved for the per-link opt-out and does not opt a page in — a page without the marker is byte-identical, nothing constructs) gets SPA-style navigation: a delegated document-level click listener resolves same-origin left-clicks (deferring to `data-gina-link*`, `data-gina-dialog*`/`data-gina-popin-*` triggers, `target`/`download` links, modified/middle clicks, in-page hash moves and `defaultPrevented`), matches the pathname against the served routing table with the server scan's own semantics — first match in table order wins, and interception happens ONLY when that match declares `negotiate: true` and accepts GET (a non-negotiable or DELETE-only first match BLOCKS, so the browser navigates normally; mixed literal+param segments and keys with neither a requirement nor a served param binding are conservatively unmatchable) — then fetches the fragment over a FRESH XMLHttpRequest per navigation carrying BOTH `X-Gina-Navigate: fragment` AND `X-Requested-With: XMLHttpRequest` (without the latter the render path re-wraps a layoutless body into an `<html>` shell), applying it ONLY when the 2xx HTML answer advertises `Vary: X-Gina-Navigate`; everything else — non-2xx, timeout, an un-negotiated answer (a client/server matching disagreement), a JSON `{location}` redirect — falls back to a normal full-page navigation. Post-swap: src-bearing scripts the document lacks are re-injected (inline scripts are NOT executed — the same contract popin content has), id-bearing forms are rebound through the live validator, an open popin is closed (a full navigation would have unloaded it), an optional `[data-gina-nav-title]` attribute in the fragment sets `document.title`, and history/scroll/focus are handled (pushState + popstate re-fetch, manual scroll restoration, focus on the region). Programmatic surface: `gina.nav.navigate(url)` and `gina.nav.matchUrl(pathname)`; the routing table is read lazily per navigation (it arrives from an async fetch and can land after `isFrameworkLoaded`). **Extends-template fragments (the block-preserving fragment parent, shipped with the module):** a negotiated layoutless render of an `{% extends %}` template renders the child's blocks — the layoutless path targets its own `fragments/` namespace under the layout cache, primed from the child's OWN block roster (declaration order, deduped) with the shell tail (xhr inputs / scripts) composed after the placeholders, and the swig compiled-template cache key carries a `:fragment` shape suffix so fragment and full-page compiles never share a slot in cached mode; the shared full-page cached layout is never touched by a fragment render (this also removed a dev cross-request poison window where one fragment request blanked the parent every full-page render compiles from). Full-page renders are byte-identical; a popin pointed at an extends-template route changes from empty to content. An extends-less template still auto-renders layoutless and cannot serve the full-page shape. ⚠️ On the nunjucks engine this class is NOT healed: the layoutless flag there filters assets only and native extends resolves the real layout, so a negotiated fragment answers the FULL page — do not ship `negotiate: true` on nunjucks extends routes (tracked separately; opt-in engine).
|
|
872
872
|
|
|
873
873
|
142. **`bin/cli` resolves the framework dir from `GINA_VERSION` (env/persisted) falling back to `package.json` version, then `require()`s `framework/v<version>/lib/generator` — now guarded by `fs.existsSync(frameworkPath)` BEFORE that require.** A `GINA_VERSION` pointing at a non-installed version (a stale pin, or a bind-mounted dev tree at a different version) otherwise throws `MODULE_NOT_FOUND` that the surrounding `try/catch` mislabels as `gina: could not load [ package.json ]` (package.json actually loaded fine), and the legitimate `existsSync` guard sits AFTER the require so it never fires — every CLI command then fails opaquely (in a container, `project:import` silently fails, so bundles report "not registered" on start). The guard now fails fast with a clear `gina framework:add <version>` message via `process.stderr.write` (`console` is not reassigned to the framework logger until later, so `console.alert` is undefined there). Diagnostic tell: `could not load package.json` followed by `Cannot find module '…/framework/v<X>/lib/generator'` means framework version `<X>` is not installed, not that package.json is broken. (`8fa9c278`, 2026-05-30) **Sibling (2026-07-03) — same guard-before-dynamic-require rule, applied to command DISPATCH.** `lib/cmd/framework/init.js` `run()` — the single chokepoint that `require()`s `/cmd/<topic>/<action>.js` for BOTH offline framework AND online bundle commands — now `fs.existsSync`-guards the handler path BEFORE requiring it. A known group (listed in `bin/cli`'s `allowedOffline`) with an UNKNOWN ACTION previously threw `Cannot find module .../cmd/<group>/<action>.js`, dumped as a raw stack by `run()`'s catch: `gina framework:connector` (connector is its OWN top-level group), `gina connector` / `gina connector --help` (auto-prefixed to the same `framework:connector` by `bin/cli:373`), and `gina -V` (→ `framework:-V`; only lowercase `-v` and `--version` are aliased) all hit it. The guard now prints a clean `'<group>:<action>' is not a valid command.` + a `gina help` / `gina help <group>` pointer, plus a did-you-mean when the action names a real command group (gated on the SAME `/^[a-z][a-z0-9-]*$/` + `<action>/help.txt`-exists test `gina help <group>` uses), exits 1, and mirrors the message to `opt.client` on the socket path; the try/catch still keeps the stack for errors thrown INSIDE a resolved handler (real crash vs. typo cleanly split by the `existsSync` check). `bin/cli:426-432` already rejected an unknown GROUP cleanly — this closes the known-group/unknown-action gap. Rule: `fs.existsSync`-guard every dynamic handler require and emit a clean unknown-command message; reserve the stack for errors from inside a resolved handler. Tests `test/lib/cmd-unknown-command.test.js`; server-side (no dist rebuild).
|
|
874
874
|
|
|
@@ -892,15 +892,15 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
892
892
|
|
|
893
893
|
206. **Observable application events surface in the dev Inspector via a per-request signal that mirrors #AISTREAM — `self.emitEvent(name, metadata)` (controller) or `lib/inspector-events.emit()` (model/service code) pushes a `{type:'event',id,name,t[,meta]}` entry and emits a live `inspector#event` frame, delivered as an `event: event` SSE/WS frame plus a `user.events` end-of-request snapshot, shown in a new SPA "Events" tab (#EVTBUS, 2026-06-29).** The per-request buffer `_devEventLog` is a THIRD key on the shared `process.gina._queryALS` store (sibling of `_devQueryLog`/`_devAiLog`, seeded at `controller.js` `setOptions`); `emit()` reaches it via `getStore()` so the snapshot is captured from any code in the request's async context (outside one — a background job / lifecycle hook — the live frame still fires but no snapshot is pushed, per Slice 2b below; a closed gate or invalid name is a no-op). Capture is gated on `NODE_ENV_IS_DEV` OR an open `process.gina._inspectorWindowUntil` (identical to the query/AI gates). The event NAME + framework stamps always ride the wire; the caller's `metadata` VALUES ride ONLY when `settings.inspector.events.captureArgs` is true (default off, seeded onto `process.gina._inspectorEventsCaptureArgs` at boot) — `lib/inspector-redact` matches secret-NAMED keys only and cannot sanitise arbitrary arg VALUES, so the gate + opt-in + authenticated channel are the protection, never redaction (same contract as #AISTREAM `captureText`). Delivery: a `server.isaac.js` SSE forwarder (`event: event`) + a `server.js` WS forwarder (`{event:'event'}`), registered/deregistered beside the data/log/token listeners; the `user.events` snapshot is attached at the SAME 5 render sites as `user.aiStream` (render-json + inspector-window-emit on `_gdUser`, render-swig cache-hit/miss + render-nunjucks on `data.page.events`), gated on `local._eventLog.length`. **Three carry-forward facts:** (1) the SPA `appendAppEvent` ACCUMULATES a capped rolling buffer (NOT single-slot-reset like `appendTokenDelta` — events are discrete, not one token stream), renders via `.textContent` (untrusted app text), and the `renderTab` `case 'events':` prefers the live buffer then falls back to the request snapshot; (2) the live `event: event` frame rides isaac-SSE + server.js-WS, and (#AISTREAM/#EVTBUS parity gap CLOSED 2026-06-29) the server.js HTTP/1 SSE `/_gina/agent` handler now forwards token + event too via `response.write` (mirroring isaac's `_agWrite` shape) — all three live transports now carry all four frame types and `event-inspection.test.js §08` is a positive pin; (3) `event` is NOT an EventSource-reserved name (unlike open/message/error) so `event: event` is collision-free. Adding the 8th SPA tab updates the 3 `TAB_LAYOUTS` presets → trips `inspector.test.js §44`'s exact-tab-set pins (now 8 tabs, updated with approval). `lib/inspector-events` registered via `_require` (stateless). There are zero pre-existing app/domain events in the framework (the only `process.emit` namespaces are `inspector#`/`logger#`/`gina#`, all internal), so the emit API is the headline — without it the signal surfaces nothing. Slice 2a (SHIPPED 2026-06-29): a curated allow-list (`settings.inspector.events.topics`, default `[]`) bridges entity-trigger emits onto the live signal via a gated block in `entity.js`'s `emit` chokepoint — skips `error`, gated on a non-empty allow-list FIRST (the opt-in IS the flood control), ships a framework-controlled `{ok,error}` summary (never raw rows; captureArgs-gated like all metadata — name+source always ride, the `{ok,error}` summary reaches the wire only when captureArgs is on, default off) tagged `source:'framework'` (a new optional 3rd arg to `inspector-events.emit` + an exported `matchTopics` helper: exact / single leading-or-trailing `*`); reached via `require('lib/inspector-events')`; request-scoped so it reuses the MVP live+snapshot delivery (no new transport/ALS key/tab). The chokepoint catches every entity emit with ZERO per-instance `.on()` / `ENTITY_MAX_LISTENERS` use (the original deferred note had conflated flood with listener-exhaustion). Only the couchbase connector emits named CRUD (`N1QL:<entity>#<method>`); the other 5 don't, and CRUD is largely redundant with the Query tab — so 2a's genuinely-new value is custom (non-query) entity methods. Slice 2b (SHIPPED 2026-06-29): an emit refactor SPLITS the store-gated buffer push from an always-on live emit — once the gate passes the live `inspector#event` frame fires store-or-not (a no-store background-job / lifecycle caller now reaches the stream; only the per-request snapshot needs a store), return `true`⟺live-frame-emitted / `false` only for gate-closed or invalid-name; this flipped §01 (`:58-63`) and CHANGED the no-op-outside-request contract, deliberately diverging from #AISTREAM (whose live `inspector#token` emit is itself store-gated at `ai/index.js` `if(_aiLog)`). On top of it a connector-lifecycle bridge surfaces a connector's recurring `ready` emit (re-fired on every reconnect) via a single additive `connector.on('ready')` at the construct-once site in `core/model/index.js this.connect` (attached once in the cache-miss branch → no per-reconnect accumulation; the existing `onReady` consumer is a one-shot `once('ready')`; couchbase is the only connector emitting `ready` today, the other 5 use a direct onReady callback; redis session-store connect/disconnect — the only other recurring pair — has no framework seam so it is deferred), live-only (lifecycle events have no request context → no snapshot, exactly the no-store path the refactor unblocked), reusing the same `_inspectorEventTopics` allow-list + `matchTopics` + `source:'framework'` tag + `{ok,error}` summary (no new config key). Tests: `test/core/event-inspection.test.js` (§01 real-module behaviour incl. the no-store live-emit, §02-§09 server wiring, §10-§11 SPA + dist propagation, §12-§14 Slice 2a: source/matchTopics + entity-bridge source-pins/replica + topics seed, §15 Slice 2b connector-lifecycle bridge: model/index.js source-pins + replica). Slices A1a `b2157113` / A1b `bb3103f1` / A1c `4f844bd6`; Slice 2b emit refactor `ea55a489` + connector-lifecycle bridge. Slices 2a + the 2b always-on-emit substrate LIVE-VERIFIED e2e 2026-06-30 (entity op → `inspector#event` frame + `user.events` snapshot; a no-store background emit → live frame), which also corrected the captureArgs note above; the couchbase-specific `couchbase#ready` reconnect→frame e2e is source + unit-replica only (no self-controlled couchbase env).
|
|
894
894
|
|
|
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, unreleased, `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, unreleased, `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, unreleased, `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, unreleased).** 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, unreleased).** `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 (unreleased) 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, unreleased).** 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, unreleased).** 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, unreleased).** 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, unreleased).** 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.
|
|
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.
|
|
896
896
|
|
|
897
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. 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`).
|
|
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
|
|
|
901
|
-
212. **Structured (JSON) logging — `GINA_LOG_FORMAT=json` + per-request `requestId`/`durationMs` (consolidates former #152/#155; #M12a/#M12b).** The logger resolves its render format ONCE at init into `opt.format` (precedence `GINA_LOG_FORMAT=json|text` > `GINA_LOG_STDOUT` truthy ⇒ json > `text` default) BEFORE containers are cloned; a JSON line is `{ts, level, bundle, message, group, msg}` — `bundle`/`message` canonical, `group`/`msg` retained as additive back-compat aliases — and the raw `console.log` path honours the format too (otherwise JSON mode would interleave plain lines and break a collector); the `text` default keeps container logs byte-identical. Per-request `requestId` + `durationMs` ride JSON logs via a NARROW `AsyncLocalStorage` (`process.gina._reqALS`, parked on `process.gina` so it survives dev require-cache busting), gated on JSON logging ONLY (text renders no id field, so the ALS would be pure overhead — the text path stays byte-identical/zero-cost): the id resolver honours a SANITISED inbound `X-Request-Id` (`/^[\w.\-]{1,128}$/`, regenerate-on-violation kills log forging) else `crypto.randomUUID()`; the `.run({requestId, startMs})` wrap sits at `handle()` — NOT the request-entry handler — because the `request.on('end')` boundary between them loses async context while `handle()`'s awaits preserve it (the original body became `_handleDispatch`; `handle` is a thin wrapper); HTTP/2 gets per-stream scoping free via Node's per-stream compat `'request'` event, NOT `session.on('stream')`; the JSON-assembly sites read `getStore()` and add `requestId` + per-line `durationMs`, gracefully absent for CLI/boot/off-request logs. `.run()`, never `enterWith()` (enterWith bleeds sideways across siblings). **MS1 (2026-07-24,
|
|
901
|
+
212. **Structured (JSON) logging — `GINA_LOG_FORMAT=json` + per-request `requestId`/`durationMs` (consolidates former #152/#155; #M12a/#M12b).** The logger resolves its render format ONCE at init into `opt.format` (precedence `GINA_LOG_FORMAT=json|text` > `GINA_LOG_STDOUT` truthy ⇒ json > `text` default) BEFORE containers are cloned; a JSON line is `{ts, level, bundle, message, group, msg}` — `bundle`/`message` canonical, `group`/`msg` retained as additive back-compat aliases — and the raw `console.log` path honours the format too (otherwise JSON mode would interleave plain lines and break a collector); the `text` default keeps container logs byte-identical. Per-request `requestId` + `durationMs` ride JSON logs via a NARROW `AsyncLocalStorage` (`process.gina._reqALS`, parked on `process.gina` so it survives dev require-cache busting), gated on JSON logging ONLY (text renders no id field, so the ALS would be pure overhead — the text path stays byte-identical/zero-cost): the id resolver honours a SANITISED inbound `X-Request-Id` (`/^[\w.\-]{1,128}$/`, regenerate-on-violation kills log forging) else `crypto.randomUUID()`; the `.run({requestId, startMs})` wrap sits at `handle()` — NOT the request-entry handler — because the `request.on('end')` boundary between them loses async context while `handle()`'s awaits preserve it (the original body became `_handleDispatch`; `handle` is a thin wrapper); HTTP/2 gets per-stream scoping free via Node's per-stream compat `'request'` event, NOT `session.on('stream')`; the JSON-assembly sites read `getStore()` and add `requestId` + per-line `durationMs`, gracefully absent for CLI/boot/off-request logs. `.run()`, never `enterWith()` (enterWith bleeds sideways across siblings). **MS1 (2026-07-24, 0.5.25):** the same id is now PROPAGATED beyond the process — every `self.query()` outbound path (the file-download proxy + both the HTTP/1 and HTTP/2 inter-bundle clients) forwards it as `x-request-id` (sourced from the resolved `req._ginaReqId`, never a raw inbound header; a caller-set value always wins), and `server.js onRequest` ECHOES `X-Request-Id` back on every response — ungated / independent of `GINA_LOG_FORMAT` (the id is always-on even when the JSON-log field isn't) and guarded against an already-sent response — so one logical request stays correlatable as it fans out across bundles and a caller/LB/APM reads the id off the wire. `controller.js`/`server.js` are server-side → no dist rebuild; restart to apply. Tests: `test/core/request-id-propagation.test.js`.
|
|
902
902
|
|
|
903
|
-
213. **Client-plugin discipline — CSP-safe listeners, native-dialog parity, preload coalescing (consolidates former #149/#159/#200).** (1) Client-bundle plugins must NOT inject inline event-handler attributes (`setAttribute('onclick', …)`, `el.onX = …`) — they trip CSP `script-src-attr` under nonce-based policies; suppress defaults with `addEventListener('click', e => e.preventDefault())`, and keep it `preventDefault`-ONLY (no stopPropagation) when the element participates in event delegation other handlers depend on — a `preventDefault`-only listener still sets `event.defaultPrevented` exactly as the inline handler did. (2) Dialog popins open as native modals (`$el.showModal()`) in EVERY env — the dev-only non-modal downgrade + manual overlay are gone for dialog mode (native `::backdrop`); a consumer that PRE-OPENS the dialog (skeleton loading) must also use `showModal()` or it is born non-modal with nothing positioning it; the opt-in `preOpen`/`loadingShell` skeleton is idempotent via `hasAttribute('open')` — NOT getAttribute truthiness, because `showModal()` sets `open` to the EMPTY string; a native UA close (Escape, `<form method="dialog">`) fires the element's `close` event WITHOUT the plugin's own close path, so a once-per-element `close` listener (the event is `close`, not `cancel` — `cancel` is Escape-only) routes it through the plugin close, keeping open-state/listeners/toolbar consistent — a UA-closed shared popin otherwise re-opens "one render behind" (#B58). (3) A popin/dialog click fired TWO identical GETs because the hover/`focusin` preload — `focusin` is part of the click gesture — was never reused: an in-flight preload registry lets the click-time consume ADOPT the in-flight fetch (waiter woken with the body, caller's own load on failure) instead of fetching again, and the legacy click path consumes preloads at all now (#B54); only the initial GET is ever preloaded. The consume path bypassing the full load tail (redirect/JSON handling) was ASSUMED acceptable — but a hover/focus-WARMED trigger whose GET returns a redirect/JSON response (`application/json` `{isXhrRedirect,location}`) had that raw JSON blind-injected as the popin body via `applyContent` (`$el.innerHTML`), the `_self` tunnel never firing — IDENTICAL symptom to the #B77 `_self` timer race but a DIFFERENT mechanism (the consume path, regression `2c61c494` v0.5.5), so #B77 fixed only the UNWARMED click-time path and the WARMED path persisted until #B80 (2026-07-06, `37e6829c`): `preloadFetch`'s `onreadystatechange` now caches a 2xx response ONLY when its Content-Type is NOT `application/json` (mirrors `popinLoad`'s own `isJsonContent` detection), firing the in-flight waiters with `null` (→ `consumePreload`'s `onMiss` = the click-time `doLoad`) and leaving the cache empty (→ a ready consume returns false → `doLoad`), so a JSON-returning trigger falls through to the click-time `popinLoad`'s full redirect/JSON tail exactly as a non-preloaded click (one extra GET on click — the hover-warm GET is not reused; measured 2 GETs). Only a genuine HTML fragment is preloaded+injected. Browser-bundled → prod dist rebuilt; VERIFIED in a real browser (a warmed legacy `data-gina-popin-url` redirect trigger now shows the tunnel target, not the raw JSON; the decline path positively exercised via resource-timing) — jsdom is falsely green. That residual — the GET itself still fired on hover for side-effecting triggers, authenticated (same-origin cookies) and CSRF-token-less (GET) — is CLOSED by the #B91 per-trigger opt-out (2026-07-10): `data-gina-dialog-preload="false"` on the trigger (honored on legacy `data-gina-popin-url` triggers too — the gate reads the attribute off the closest-matched intent target; case-INSENSITIVE `/^false$/i` on purpose, so a templated "False" cannot fail open and fire the GET anyway — deliberately safer than data-gina-dialog-modal's case-sensitive parse) suppresses BOTH the hover warm and the focusin-on-click warm at the shared intent handler; the opted-out click loads normally at click time (undefined cache slot → consume returns false → the caller's click-time load; exactly one GET per click). Default stays preload-ON: GET is presumed safe to fire early per HTTP semantics — a side-effecting trigger declares itself (the hover-prefetch ecosystem's convention, live-verified). (4) A proxied/tunnelled redirect JSON response (`isXhrRedirect`+`location`, default `_self`) used to `$popin.load()` then arm a blind `setTimeout(50, () => !$popin.isOpen && $popin.open())`; a follow-up load slower than 50 ms opened the popin against a not-yet-injected (skeleton/empty) target → intermittent unhandled-deref crash. Removed as vestigial (v0.1.0, 2021) — the already-armed `loaded.<id>` listener opens CONTENT-FIRST (injects the body via popinBind/handleLoadedBody, THEN popinOpen), so the load alone suffices; the `_self` branch's `return;` still guards the `window.open` fall-through (non-`_self` targets only) (#B77). Event contract: `open.<id>` = a fresh open, `loaded.<id>` = reload/redirect content injected — a consumer needing "content ready on every load" must listen to BOTH. A structurally identical blind timer lived in the SIBLING validator `Validator::Popin now redirecting` path (`core/plugins/lib/validator/src/main.js`), removed by #B79 (2026-07-10): the validator DISCARDED popinLoad's return, and that return IS the `{open}` handle whose `open()` arms the content-first `loaded.<id>` listener — so the timer was papering over a race the discarded handle created (an XHR faster than 50 ms fired `loaded` into the void and the body was lost, a slower one opened an empty popin first). It now captures the handle and calls `open()` inside the same not-open gate, guarding the `undefined` popinLoad returns when the request cannot start (CORS unsupported) so a failed load never blind-opens an empty popin; and the cross-popin branch resets `isRedirecting` before closing the ORIGINAL popin, because `popinClose` early-bails on a redirecting popin — that close was a silent no-op, leaving the original open behind the new one (browser-verified: pre-fix stays open, post-fix closes). MEASURED CORRECTION: the timer was NOT load-bearing for the different-popin branch as long assumed — that branch threw first, because the published `gina.popin` WAS re-assembled with a target-wins merge on every `new Popin()`, so its `getPopinByName`/`getPopinById` stayed bound to the FIRST instance's registry and resolved ONLY the boot popin, while `$popins` + `getActivePopin` saw every popin and `gina.popin.activePopinId` never updated (so `getActivePopin()` returned null once nothing was open) — and popins registered AFTER an instance's publish (click-time in-page dialog registrations) never reached the published registry at all, with destroyed popins lingering in it. FIXED as #B90 (2026-07-10): ONE module-scoped registry (`_sharedPopins`) aliased by every instance's `$popins`; a publish-ONCE `gina.popin = instance` (the published object IS the first instance, LIVE — re-publishing was both the freeze defect and, with a shared registry, a self-merge recursion hazard: gina's merge deep-recurses plain objects and `$popin.eventData` can hold the $popin itself); and a `setActivePopinId` write-through helper at all 7 write sites keeping the published `activePopinId` truthful no matter which instance opens or closes a popin. Browser-verified both ways on the built bundle (pre-fix: accessors blind + the validator cross-popin redirect 422-throws `not found`; fixed: accessors resolve every instance's popins and a form submit redirecting into a DIFFERENT popin works end-to-end — the original closes, the target loads content-first, `activePopinId` follows). Generalises: an accessor published through a target-wins `merge()` silently keeps the first instance's closure — publish a live instance once and share module-scoped state instead of re-merging per construction; and never infer a code path is reachable from the fact that it exists. (5) EAGER content warm — `data-gina-dialog-preload="eager"` (case-insensitive) opts a trigger into a one-shot idle warm-all pass: after `window` load, on requestIdleCallback (setTimeout fallback), serialized one GET at a time, routed through the SAME shared per-trigger gate as the hover warm (`warmTrigger`: disabled skip → the `"false"` opt-out → URL-cache dedup → in-flight reserve + fetch) so the two warm paths cannot drift and whichever fires second is a no-op; skipped entirely under Save-Data; triggers injected after the pass keep the delegated hover warm; staleness matches the shipped no-TTL hover semantics — the opt-in accepts the wider warm→open window. (6) #B139 (2026-07-20, rides the next cut): the content cache no longer outlives the open it warmed — measured pre-fix it was a ONE-GENERATION-LAGGING store (every open after the first paid ~1 GET yet rendered the PREVIOUS open's fetch: the leftover entry — the #B54 in-flight adoption's repopulated body, or the around-open re-warm — blocked the fresh warm's dedup; NO invalidation path existed, close/unbind/destroy never touched the cache; reproduced back to 0.5.4, so it predates #B54 and the eager pass). Fix: close clears the popin's content-URL slot (stamped at consumePreload both branches + popinLoad) AND re-sweeps the same slot ~120ms later, because the close-time a11y focus-return and pointer re-hover fire TRUSTED synthetic intents that re-warm the slot with close-era content within 1ms of the close (measured; a raced sweep is benign by construction — at most one extra fetch, never staleness, since an adopted in-flight body still reaches its open through the waiter chain). AND `preload="false"` triggers now skip the cache READ at open on both open paths — `false` is the hard always-refetch spelling for volatile popins (its GET always happens at open, never from a same-URL sibling's warm); no new annotation vocabulary. Warmed-never-opened entries keep the page-lifetime eager semantics above; default triggers pay at most one extra idempotent GET per close (the swept synthetic warm). Content preload deliberately does NOT ride 103 Early Hints: popin responses vary on `X-Requested-With` (a non-XHR GET of a fragment layout gets the iframe-wrap variant and misses the scripts append), and a browser `Link rel=preload` fetch never carries that header — wrong-variant bytes or a double GET, with no success branch; `self.setEarlyHints()` remains the right tool for a popin's STATIC subassets (the CSS/JS it injects via getScript/getStyle). (7) #A11Y8 (2026-08-05, rides the next cut): the background-`inert` loop in `applyNonModalShims` skipped `instance.target` — the shared `.gina-popins` container — and since every popin lives INSIDE that container, and `popinOpen` never closes the popin it supersedes (it only overwrites `activePopinId`), opening a second NON-MODAL dialog left the first one fully keyboard-reachable behind it: a user could tab out of the dialog on screen and into a stale one. Non-modal is the FRAMEWORK DEFAULT for `data-gina-dialog` (`resolveModal` falls through to `return false` at step 5; only a legacy `data-gina-popin-name` trigger is unconditionally modal), so this was the ordinary path, not an exotic one — note item (2)'s "native modals in EVERY env" predates the non-modal branch and now describes LEGACY triggers only. Fix: descend into the container instead of skipping it, and inert sibling `dialog[open]` ONLY — a `<dialog>` without `open` is already `display:none` per the UA stylesheet, so it is unreachable without help (measured in Chrome with controls firing both directions; the modal path was never affected, native `showModal()` handles the top layer itself). Teardown needed NO change: `removeNonModalShims` sweeps `[data-gina-popin-inert]` document-wide, so dialogs nested in the container restore without it knowing they exist. The `getAttribute('inert') == null` guard means gina only marks what it set, so a consumer-set `inert` is never stamped and never stripped. All seven are browser-bundled — prod dist rebuild required; verify (2), (3), (4) and (5) with a REAL preemptive-open / preload / redirect-tunnel consumer — a minimal smoke page is falsely green; (7) needs TWO popins open at once, the only scene in which it differs at all. (8) **A trigger gate must not trust the native `disabled` ATTRIBUTE on an element the browser already enforces (#B296, 0.6.5).** The three DISPATCH-time gates (`openFromTrigger`, the `bindOpen` document proxy, the per-`$element` listener) now require `!('disabled' in $el)` before the attribute counts, mirroring the validator's `isTriggerDisabled` (#B293) so the two subsystems agree on what "disabled" means. Rationale, measured: a natively-disabled `<button>` delivers **0 clicks to JS**, so the attribute arm could only ever fire on a `disabled` written DURING the dispatch — the shape of every consumer double-submit guard — which ate the open and then cleared the attribute, leaving nothing marked and a normal-looking control; on an `<a>`/custom element/span the browser enforces nothing, so the arm is KEPT (`'disabled' in $el` → `button:true · input:true · anchor:false · custom-element:false · span:false`). It also cannot weaken gina's OWN re-entry guard: `armPopinTrigger` arms `<a>` with `aria-disabled` (untouched arm) and every other tag with native `disabled` — enforced by the browser for real controls, and still enforced by this gate for `<span>`-likes, which is exactly the branch the `in` test preserves. **Two gates are deliberately NOT changed:** the hover/focus `warmTrigger` preload gate (removing it there would start PRELOADING a `<button disabled>` nobody can click — if it ever changes it wants the IDL property `$el.disabled === true`, not the ignore treatment) and the bind-time `$link` skip (structurally unreachable by a mid-dispatch write, and it carries a different one-arm predicate). **Reachability is unequal and the difference is load-bearing:** at `openFromTrigger` gina's proxy is DELEGATED so a button-bound guard always wins the race (plain #B293 shape reproduces); at the per-`$element` listener gina binds to the NODE, so only a CAPTURE-phase or earlier-registered guard can poison it (measured both ways — a target-phase guard bound after gina's does NOT reproduce); the legacy document-proxy site is traced-not-exercised. Test `test/e2e/popin-trigger-native-disabled.spec.js` — a replica cannot catch this, it is an ordering interaction between a consumer listener and a delegated proxy. ⚠️ **Scene trap for anyone writing a popin close-button test:** the close element must carry NO `id` — gina assigns `popin.close.<n>` only to an id-less one and the dispatch gate matches on that prefix, so a consumer id makes EVERY arm read "did not close", subtract control included (that is its own defect, #B299). (9) **A teardown loop must never splice the array it is walking (#B265, 0.6.5).** `popinUnbind`'s validator-form teardown spliced `$popin['$forms']` inside a `for` bounded by a length captured BEFORE the loop, so each splice shifted the array left under an incrementing cursor and the read index skipped one every time — the originally ODD-indexed forms were never destroyed and were left behind in the very array the loop exists to empty. Because the popin's `innerHTML = ''` runs BEFORE the loop, their surviving validator entries pointed at already-detached nodes, and `validateFormById`'s "return existing when available" early return then handed the stale entry back on the next open, so the form and its submit trigger came up silently inert — the same end symptom as #B294 by a completely different route. Fix: snapshot with `.slice()`, clear once with `.length = 0`, iterate the snapshot. **Two things worth carrying beyond this bug.** First, `destroy()` is not free: it fires `destroy.<formId>` (validator `main.js:4241`) and `'destroy'` IS on the plugin's public registrable-event list, so making a skipped form destroy properly starts firing consumer `on('destroy')` callbacks that never fired before — a teardown fix is a behaviour change, not merely a leak fix. Second, per-form teardown is now wrapped: `destroy()` → `unbindForm` → `getFormById(vFormId)` THROWS for a form holding a `data-gina-form-virtual` file input once its virtual form is unresolvable, which is precisely the detached state every form is in at that point — and before the guard, one such throw silently abandoned every LATER form in the loop. **Reachability is much narrower than it looks and must not be overstated:** the loop is gated on `$validatorInstance`, a PER-INSTANCE closure assigned only from `options.validator`; gina's own boot does `new Popin({name:'gina-dialog-boot'})` with no validator, and the delegated `data-gina-dialog` listener is installed once by that boot instance (module guard `_ginaDialogDelegated`), so **the declarative dialog path can never reach this code** — it needs the legacy popin API driven by an explicitly-constructed validator-carrying instance. That is also why the test is a replica + source pins (`test/core/popin-forms-teardown.test.js`) rather than an e2e arm: an e2e scene on the declarative path would be VOID, every arm reading "nothing destroyed" for a reason unrelated to the defect. (10) **Never re-derive an element's ROLE from its id when the call path already proves it (#B299 + #B301, 0.6.5).** `register()`'s close branch gated on `/^popin\.close\./.test(event.target.id)` — a prefix gina mints at bind time — and that single line carried TWO silent defects: gina assigns the prefixed id **only to an id-less element** (`:1560`), so a consumer-supplied id is taken verbatim and matches nothing (**#B299**); and `event.target` is whatever was actually CLICKED, so an icon nested inside the button matches nothing either (**#B301** — the ordinary `<button class="gina-popin-close"><svg/></button>`, and by far the wider case). Both are invisible: `cancelEvent` runs at the top of the listener, so the button also swallows its own default — no error, no navigation, nothing. Fix: read **`event.currentTarget`** (the element `register()` bound — a `.gina-popin-close` by construction, since that function has ONE call site fed only from `querySelectorAll('.gina-popin-close')`) and treat a `popin.click.*` id as the sole exception, which preserves the dual-role element (a trigger that is ALSO a close button — reachable because `bindOpen` scans `document`, not just the host page). ⚠️ **What must NOT be "fixed" instead: rewriting the element's id.** The `$close` teardown sweep removes this listener via `gina.events[eId] == eId` — the degenerate name===id entry that `events.js:42` produces — so decoupling the event name from the id leaks the listener; and overwriting a consumer id would break their selectors, `aria-labelledby` and test hooks. Contrast `bindOpen:1195-1203`, which takes the OPPOSITE policy and does clobber a consumer id on trigger elements. Measured with controls firing both directions on the real bundle (id-less closes / consumer-id does not / nested click does not / patched all close), test `test/e2e/popin-close-dispatch.spec.js`. (11) **gina's own in-flight arm must be honoured by the path that dispatches it — a legacy trigger re-fired during its own load (#B298, 0.6.5).** `armPopinTrigger` marks an `<a>` trigger `aria-disabled="true"` for the duration of its popin's load (every other tag gets the native `disabled`, which the browser enforces). But the ONLY dispatch route for a pure-legacy `data-gina-popin-name` trigger is the `bindOpen` document click proxy, whose predicate tests the native attribute ALONE — and an `<a>` never carries it here — so a second click during the load reached the open handler and issued a SECOND XHR. `bindDelegatedOpen` returns early for a trigger carrying neither `data-gina-dialog` nor `data-gina-dialog-src`, so `openFromTrigger`'s own `aria-disabled` arm never covered this population. Fix: gate at the top of the legacy per-element open handler, predicate copied VERBATIM from `openFromTrigger` (native arm included, so #B296's `!('disabled' in $el)` rule still holds). **Gating THERE rather than in the proxy is load-bearing, and was MEASURED rather than reasoned:** a trigger's direct children each get their own listener from `proxyClick`, which fires the custom event DIRECTLY and never passes the proxy predicate at all — a proxy-level gate still let the child-click route fire twice, while the handler-level gate stops both (both routes converge there, and `currentTarget` is the element `bindOpen` bound, so child markup cannot dodge it). ⚠️ **Reachability is narrower than it looks, and the scene is easy to get wrong:** a real click HOVERS first, which warms the preload (`warmTrigger` reads `data-gina-popin-url` too), and the click then ADOPTS that in-flight fetch instead of calling `popinLoad` — so nothing is ever armed and the question goes unasked (a probe without the opt-out reads 1 request for a reason unrelated to the gate, its own counter-can-read-2 control failing). The defect needs the documented `data-gina-dialog-preload="false"` opt-out (#B91) — i.e. exactly the triggers whose GET has server-side effects, which is why it is not cosmetic. Whether a no-hover activation (touch, some keyboard paths) is a second reachable population is UNMEASURED. Test `test/e2e/popin-legacy-trigger-aria-disabled.spec.js` (4 arms, red-first; the two fix arms failed 2-vs-1 on the pre-fix dist while their scene/armed/click assertions passed, so the red is attributable to the defect). (12) **A click adopting a still-in-flight hover/focus preload is a genuine wait — it now arms the trigger exactly like the cold click-time XHR (#B285, 0.6.5).** Both consume branches previously armed NOTHING (only a preOpen popin got its skeleton), so with warm-on-intent preloading the COMMON open path showed no busy affordance at all. `consumePreload` gained a guarded 4th param `onSettled`, fired synchronously after the ready (cached) apply and at the TOP of the adopted waiter — success AND failure, release-before-apply, the cold readyState-4 order. The CALLERS own the affordance (arm on adopted-wait only via a settled-flag; release via `releasePopinTrigger`, armPopinTrigger's mirror), keeping `consumePreload` loading-state-agnostic — load-bearing for the extract-and-execute test harness, which injects neither `document` nor `loadingState` and calls with ≤3 args (the guard makes the 4th a no-op), and the right factoring anyway (it reports that it settled; callers decide who cares). The ready branch's SYNCHRONOUS settle is equally load-bearing: without it a caller cannot distinguish ready from in-flight and would arm forever — an instant cached open never flashes a busy state. Double-click protection during the wait comes free from EXISTING mechanisms (the entry gates refuse an armed trigger; the browser suppresses a disabled `<button>`), and the failed-adoption leg releases before the click-time fallback re-arms through popinLoad's own path. Playwright note: an armed `<a>` reads [disabled] in the accessibility tree, so Playwright's own actionability check refuses to click it — a second-click scene needs `force: true` (a real pointer is NOT blocked on aria-disabled). e2e `test/e2e/popin-preload-loading-state.spec.js` (5 arms, red-first: 4 red on the pre-fix dist, ready-branch no-arm control green on both).
|
|
903
|
+
213. **Client-plugin discipline — CSP-safe listeners, native-dialog parity, preload coalescing (consolidates former #149/#159/#200).** (1) Client-bundle plugins must NOT inject inline event-handler attributes (`setAttribute('onclick', …)`, `el.onX = …`) — they trip CSP `script-src-attr` under nonce-based policies; suppress defaults with `addEventListener('click', e => e.preventDefault())`, and keep it `preventDefault`-ONLY (no stopPropagation) when the element participates in event delegation other handlers depend on — a `preventDefault`-only listener still sets `event.defaultPrevented` exactly as the inline handler did. (2) Dialog popins open as native modals (`$el.showModal()`) in EVERY env — the dev-only non-modal downgrade + manual overlay are gone for dialog mode (native `::backdrop`); a consumer that PRE-OPENS the dialog (skeleton loading) must also use `showModal()` or it is born non-modal with nothing positioning it; the opt-in `preOpen`/`loadingShell` skeleton is idempotent via `hasAttribute('open')` — NOT getAttribute truthiness, because `showModal()` sets `open` to the EMPTY string; a native UA close (Escape, `<form method="dialog">`) fires the element's `close` event WITHOUT the plugin's own close path, so a once-per-element `close` listener (the event is `close`, not `cancel` — `cancel` is Escape-only) routes it through the plugin close, keeping open-state/listeners/toolbar consistent — a UA-closed shared popin otherwise re-opens "one render behind" (#B58). (3) A popin/dialog click fired TWO identical GETs because the hover/`focusin` preload — `focusin` is part of the click gesture — was never reused: an in-flight preload registry lets the click-time consume ADOPT the in-flight fetch (waiter woken with the body, caller's own load on failure) instead of fetching again, and the legacy click path consumes preloads at all now (#B54); only the initial GET is ever preloaded. The consume path bypassing the full load tail (redirect/JSON handling) was ASSUMED acceptable — but a hover/focus-WARMED trigger whose GET returns a redirect/JSON response (`application/json` `{isXhrRedirect,location}`) had that raw JSON blind-injected as the popin body via `applyContent` (`$el.innerHTML`), the `_self` tunnel never firing — IDENTICAL symptom to the #B77 `_self` timer race but a DIFFERENT mechanism (the consume path, regression `2c61c494` v0.5.5), so #B77 fixed only the UNWARMED click-time path and the WARMED path persisted until #B80 (2026-07-06, `37e6829c`): `preloadFetch`'s `onreadystatechange` now caches a 2xx response ONLY when its Content-Type is NOT `application/json` (mirrors `popinLoad`'s own `isJsonContent` detection), firing the in-flight waiters with `null` (→ `consumePreload`'s `onMiss` = the click-time `doLoad`) and leaving the cache empty (→ a ready consume returns false → `doLoad`), so a JSON-returning trigger falls through to the click-time `popinLoad`'s full redirect/JSON tail exactly as a non-preloaded click (one extra GET on click — the hover-warm GET is not reused; measured 2 GETs). Only a genuine HTML fragment is preloaded+injected. Browser-bundled → prod dist rebuilt; VERIFIED in a real browser (a warmed legacy `data-gina-popin-url` redirect trigger now shows the tunnel target, not the raw JSON; the decline path positively exercised via resource-timing) — jsdom is falsely green. That residual — the GET itself still fired on hover for side-effecting triggers, authenticated (same-origin cookies) and CSRF-token-less (GET) — is CLOSED by the #B91 per-trigger opt-out (2026-07-10): `data-gina-dialog-preload="false"` on the trigger (honored on legacy `data-gina-popin-url` triggers too — the gate reads the attribute off the closest-matched intent target; case-INSENSITIVE `/^false$/i` on purpose, so a templated "False" cannot fail open and fire the GET anyway — deliberately safer than data-gina-dialog-modal's case-sensitive parse) suppresses BOTH the hover warm and the focusin-on-click warm at the shared intent handler; the opted-out click loads normally at click time (undefined cache slot → consume returns false → the caller's click-time load; exactly one GET per click). Default stays preload-ON: GET is presumed safe to fire early per HTTP semantics — a side-effecting trigger declares itself (the hover-prefetch ecosystem's convention, live-verified). (4) A proxied/tunnelled redirect JSON response (`isXhrRedirect`+`location`, default `_self`) used to `$popin.load()` then arm a blind `setTimeout(50, () => !$popin.isOpen && $popin.open())`; a follow-up load slower than 50 ms opened the popin against a not-yet-injected (skeleton/empty) target → intermittent unhandled-deref crash. Removed as vestigial (v0.1.0, 2021) — the already-armed `loaded.<id>` listener opens CONTENT-FIRST (injects the body via popinBind/handleLoadedBody, THEN popinOpen), so the load alone suffices; the `_self` branch's `return;` still guards the `window.open` fall-through (non-`_self` targets only) (#B77). Event contract: `open.<id>` = a fresh open, `loaded.<id>` = reload/redirect content injected — a consumer needing "content ready on every load" must listen to BOTH. A structurally identical blind timer lived in the SIBLING validator `Validator::Popin now redirecting` path (`core/plugins/lib/validator/src/main.js`), removed by #B79 (2026-07-10): the validator DISCARDED popinLoad's return, and that return IS the `{open}` handle whose `open()` arms the content-first `loaded.<id>` listener — so the timer was papering over a race the discarded handle created (an XHR faster than 50 ms fired `loaded` into the void and the body was lost, a slower one opened an empty popin first). It now captures the handle and calls `open()` inside the same not-open gate, guarding the `undefined` popinLoad returns when the request cannot start (CORS unsupported) so a failed load never blind-opens an empty popin; and the cross-popin branch resets `isRedirecting` before closing the ORIGINAL popin, because `popinClose` early-bails on a redirecting popin — that close was a silent no-op, leaving the original open behind the new one (browser-verified: pre-fix stays open, post-fix closes). MEASURED CORRECTION: the timer was NOT load-bearing for the different-popin branch as long assumed — that branch threw first, because the published `gina.popin` WAS re-assembled with a target-wins merge on every `new Popin()`, so its `getPopinByName`/`getPopinById` stayed bound to the FIRST instance's registry and resolved ONLY the boot popin, while `$popins` + `getActivePopin` saw every popin and `gina.popin.activePopinId` never updated (so `getActivePopin()` returned null once nothing was open) — and popins registered AFTER an instance's publish (click-time in-page dialog registrations) never reached the published registry at all, with destroyed popins lingering in it. FIXED as #B90 (2026-07-10): ONE module-scoped registry (`_sharedPopins`) aliased by every instance's `$popins`; a publish-ONCE `gina.popin = instance` (the published object IS the first instance, LIVE — re-publishing was both the freeze defect and, with a shared registry, a self-merge recursion hazard: gina's merge deep-recurses plain objects and `$popin.eventData` can hold the $popin itself); and a `setActivePopinId` write-through helper at all 7 write sites keeping the published `activePopinId` truthful no matter which instance opens or closes a popin. Browser-verified both ways on the built bundle (pre-fix: accessors blind + the validator cross-popin redirect 422-throws `not found`; fixed: accessors resolve every instance's popins and a form submit redirecting into a DIFFERENT popin works end-to-end — the original closes, the target loads content-first, `activePopinId` follows). Generalises: an accessor published through a target-wins `merge()` silently keeps the first instance's closure — publish a live instance once and share module-scoped state instead of re-merging per construction; and never infer a code path is reachable from the fact that it exists. (5) EAGER content warm — `data-gina-dialog-preload="eager"` (case-insensitive) opts a trigger into a one-shot idle warm-all pass: after `window` load, on requestIdleCallback (setTimeout fallback), serialized one GET at a time, routed through the SAME shared per-trigger gate as the hover warm (`warmTrigger`: disabled skip → the `"false"` opt-out → URL-cache dedup → in-flight reserve + fetch) so the two warm paths cannot drift and whichever fires second is a no-op; skipped entirely under Save-Data; triggers injected after the pass keep the delegated hover warm; staleness matches the shipped no-TTL hover semantics — the opt-in accepts the wider warm→open window. (6) #B139 (2026-07-20, 0.5.22): the content cache no longer outlives the open it warmed — measured pre-fix it was a ONE-GENERATION-LAGGING store (every open after the first paid ~1 GET yet rendered the PREVIOUS open's fetch: the leftover entry — the #B54 in-flight adoption's repopulated body, or the around-open re-warm — blocked the fresh warm's dedup; NO invalidation path existed, close/unbind/destroy never touched the cache; reproduced back to 0.5.4, so it predates #B54 and the eager pass). Fix: close clears the popin's content-URL slot (stamped at consumePreload both branches + popinLoad) AND re-sweeps the same slot ~120ms later, because the close-time a11y focus-return and pointer re-hover fire TRUSTED synthetic intents that re-warm the slot with close-era content within 1ms of the close (measured; a raced sweep is benign by construction — at most one extra fetch, never staleness, since an adopted in-flight body still reaches its open through the waiter chain). AND `preload="false"` triggers now skip the cache READ at open on both open paths — `false` is the hard always-refetch spelling for volatile popins (its GET always happens at open, never from a same-URL sibling's warm); no new annotation vocabulary. Warmed-never-opened entries keep the page-lifetime eager semantics above; default triggers pay at most one extra idempotent GET per close (the swept synthetic warm). Content preload deliberately does NOT ride 103 Early Hints: popin responses vary on `X-Requested-With` (a non-XHR GET of a fragment layout gets the iframe-wrap variant and misses the scripts append), and a browser `Link rel=preload` fetch never carries that header — wrong-variant bytes or a double GET, with no success branch; `self.setEarlyHints()` remains the right tool for a popin's STATIC subassets (the CSS/JS it injects via getScript/getStyle). (7) #A11Y8 (2026-08-05, 0.6.4): the background-`inert` loop in `applyNonModalShims` skipped `instance.target` — the shared `.gina-popins` container — and since every popin lives INSIDE that container, and `popinOpen` never closes the popin it supersedes (it only overwrites `activePopinId`), opening a second NON-MODAL dialog left the first one fully keyboard-reachable behind it: a user could tab out of the dialog on screen and into a stale one. Non-modal is the FRAMEWORK DEFAULT for `data-gina-dialog` (`resolveModal` falls through to `return false` at step 5; only a legacy `data-gina-popin-name` trigger is unconditionally modal), so this was the ordinary path, not an exotic one — note item (2)'s "native modals in EVERY env" predates the non-modal branch and now describes LEGACY triggers only. Fix: descend into the container instead of skipping it, and inert sibling `dialog[open]` ONLY — a `<dialog>` without `open` is already `display:none` per the UA stylesheet, so it is unreachable without help (measured in Chrome with controls firing both directions; the modal path was never affected, native `showModal()` handles the top layer itself). Teardown needed NO change: `removeNonModalShims` sweeps `[data-gina-popin-inert]` document-wide, so dialogs nested in the container restore without it knowing they exist. The `getAttribute('inert') == null` guard means gina only marks what it set, so a consumer-set `inert` is never stamped and never stripped. All seven are browser-bundled — prod dist rebuild required; verify (2), (3), (4) and (5) with a REAL preemptive-open / preload / redirect-tunnel consumer — a minimal smoke page is falsely green; (7) needs TWO popins open at once, the only scene in which it differs at all. (8) **A trigger gate must not trust the native `disabled` ATTRIBUTE on an element the browser already enforces (#B296, 0.6.5).** The three DISPATCH-time gates (`openFromTrigger`, the `bindOpen` document proxy, the per-`$element` listener) now require `!('disabled' in $el)` before the attribute counts, mirroring the validator's `isTriggerDisabled` (#B293) so the two subsystems agree on what "disabled" means. Rationale, measured: a natively-disabled `<button>` delivers **0 clicks to JS**, so the attribute arm could only ever fire on a `disabled` written DURING the dispatch — the shape of every consumer double-submit guard — which ate the open and then cleared the attribute, leaving nothing marked and a normal-looking control; on an `<a>`/custom element/span the browser enforces nothing, so the arm is KEPT (`'disabled' in $el` → `button:true · input:true · anchor:false · custom-element:false · span:false`). It also cannot weaken gina's OWN re-entry guard: `armPopinTrigger` arms `<a>` with `aria-disabled` (untouched arm) and every other tag with native `disabled` — enforced by the browser for real controls, and still enforced by this gate for `<span>`-likes, which is exactly the branch the `in` test preserves. **Two gates are deliberately NOT changed:** the hover/focus `warmTrigger` preload gate (removing it there would start PRELOADING a `<button disabled>` nobody can click — if it ever changes it wants the IDL property `$el.disabled === true`, not the ignore treatment) and the bind-time `$link` skip (structurally unreachable by a mid-dispatch write, and it carries a different one-arm predicate). **Reachability is unequal and the difference is load-bearing:** at `openFromTrigger` gina's proxy is DELEGATED so a button-bound guard always wins the race (plain #B293 shape reproduces); at the per-`$element` listener gina binds to the NODE, so only a CAPTURE-phase or earlier-registered guard can poison it (measured both ways — a target-phase guard bound after gina's does NOT reproduce); the legacy document-proxy site is traced-not-exercised. Test `test/e2e/popin-trigger-native-disabled.spec.js` — a replica cannot catch this, it is an ordering interaction between a consumer listener and a delegated proxy. ⚠️ **Scene trap for anyone writing a popin close-button test:** the close element must carry NO `id` — gina assigns `popin.close.<n>` only to an id-less one and the dispatch gate matches on that prefix, so a consumer id makes EVERY arm read "did not close", subtract control included (that is its own defect, #B299). (9) **A teardown loop must never splice the array it is walking (#B265, 0.6.5).** `popinUnbind`'s validator-form teardown spliced `$popin['$forms']` inside a `for` bounded by a length captured BEFORE the loop, so each splice shifted the array left under an incrementing cursor and the read index skipped one every time — the originally ODD-indexed forms were never destroyed and were left behind in the very array the loop exists to empty. Because the popin's `innerHTML = ''` runs BEFORE the loop, their surviving validator entries pointed at already-detached nodes, and `validateFormById`'s "return existing when available" early return then handed the stale entry back on the next open, so the form and its submit trigger came up silently inert — the same end symptom as #B294 by a completely different route. Fix: snapshot with `.slice()`, clear once with `.length = 0`, iterate the snapshot. **Two things worth carrying beyond this bug.** First, `destroy()` is not free: it fires `destroy.<formId>` (validator `main.js:4241`) and `'destroy'` IS on the plugin's public registrable-event list, so making a skipped form destroy properly starts firing consumer `on('destroy')` callbacks that never fired before — a teardown fix is a behaviour change, not merely a leak fix. Second, per-form teardown is now wrapped: `destroy()` → `unbindForm` → `getFormById(vFormId)` THROWS for a form holding a `data-gina-form-virtual` file input once its virtual form is unresolvable, which is precisely the detached state every form is in at that point — and before the guard, one such throw silently abandoned every LATER form in the loop. **Reachability is much narrower than it looks and must not be overstated:** the loop is gated on `$validatorInstance`, a PER-INSTANCE closure assigned only from `options.validator`; gina's own boot does `new Popin({name:'gina-dialog-boot'})` with no validator, and the delegated `data-gina-dialog` listener is installed once by that boot instance (module guard `_ginaDialogDelegated`), so **the declarative dialog path can never reach this code** — it needs the legacy popin API driven by an explicitly-constructed validator-carrying instance. That is also why the test is a replica + source pins (`test/core/popin-forms-teardown.test.js`) rather than an e2e arm: an e2e scene on the declarative path would be VOID, every arm reading "nothing destroyed" for a reason unrelated to the defect. (10) **Never re-derive an element's ROLE from its id when the call path already proves it (#B299 + #B301, 0.6.5).** `register()`'s close branch gated on `/^popin\.close\./.test(event.target.id)` — a prefix gina mints at bind time — and that single line carried TWO silent defects: gina assigns the prefixed id **only to an id-less element** (`:1560`), so a consumer-supplied id is taken verbatim and matches nothing (**#B299**); and `event.target` is whatever was actually CLICKED, so an icon nested inside the button matches nothing either (**#B301** — the ordinary `<button class="gina-popin-close"><svg/></button>`, and by far the wider case). Both are invisible: `cancelEvent` runs at the top of the listener, so the button also swallows its own default — no error, no navigation, nothing. Fix: read **`event.currentTarget`** (the element `register()` bound — a `.gina-popin-close` by construction, since that function has ONE call site fed only from `querySelectorAll('.gina-popin-close')`) and treat a `popin.click.*` id as the sole exception, which preserves the dual-role element (a trigger that is ALSO a close button — reachable because `bindOpen` scans `document`, not just the host page). ⚠️ **What must NOT be "fixed" instead: rewriting the element's id.** The `$close` teardown sweep removes this listener via `gina.events[eId] == eId` — the degenerate name===id entry that `events.js:42` produces — so decoupling the event name from the id leaks the listener; and overwriting a consumer id would break their selectors, `aria-labelledby` and test hooks. Contrast `bindOpen:1195-1203`, which takes the OPPOSITE policy and does clobber a consumer id on trigger elements. Measured with controls firing both directions on the real bundle (id-less closes / consumer-id does not / nested click does not / patched all close), test `test/e2e/popin-close-dispatch.spec.js`. (11) **gina's own in-flight arm must be honoured by the path that dispatches it — a legacy trigger re-fired during its own load (#B298, 0.6.5).** `armPopinTrigger` marks an `<a>` trigger `aria-disabled="true"` for the duration of its popin's load (every other tag gets the native `disabled`, which the browser enforces). But the ONLY dispatch route for a pure-legacy `data-gina-popin-name` trigger is the `bindOpen` document click proxy, whose predicate tests the native attribute ALONE — and an `<a>` never carries it here — so a second click during the load reached the open handler and issued a SECOND XHR. `bindDelegatedOpen` returns early for a trigger carrying neither `data-gina-dialog` nor `data-gina-dialog-src`, so `openFromTrigger`'s own `aria-disabled` arm never covered this population. Fix: gate at the top of the legacy per-element open handler, predicate copied VERBATIM from `openFromTrigger` (native arm included, so #B296's `!('disabled' in $el)` rule still holds). **Gating THERE rather than in the proxy is load-bearing, and was MEASURED rather than reasoned:** a trigger's direct children each get their own listener from `proxyClick`, which fires the custom event DIRECTLY and never passes the proxy predicate at all — a proxy-level gate still let the child-click route fire twice, while the handler-level gate stops both (both routes converge there, and `currentTarget` is the element `bindOpen` bound, so child markup cannot dodge it). ⚠️ **Reachability is narrower than it looks, and the scene is easy to get wrong:** a real click HOVERS first, which warms the preload (`warmTrigger` reads `data-gina-popin-url` too), and the click then ADOPTS that in-flight fetch instead of calling `popinLoad` — so nothing is ever armed and the question goes unasked (a probe without the opt-out reads 1 request for a reason unrelated to the gate, its own counter-can-read-2 control failing). The defect needs the documented `data-gina-dialog-preload="false"` opt-out (#B91) — i.e. exactly the triggers whose GET has server-side effects, which is why it is not cosmetic. Whether a no-hover activation (touch, some keyboard paths) is a second reachable population is UNMEASURED. Test `test/e2e/popin-legacy-trigger-aria-disabled.spec.js` (4 arms, red-first; the two fix arms failed 2-vs-1 on the pre-fix dist while their scene/armed/click assertions passed, so the red is attributable to the defect). (12) **A click adopting a still-in-flight hover/focus preload is a genuine wait — it now arms the trigger exactly like the cold click-time XHR (#B285, 0.6.5).** Both consume branches previously armed NOTHING (only a preOpen popin got its skeleton), so with warm-on-intent preloading the COMMON open path showed no busy affordance at all. `consumePreload` gained a guarded 4th param `onSettled`, fired synchronously after the ready (cached) apply and at the TOP of the adopted waiter — success AND failure, release-before-apply, the cold readyState-4 order. The CALLERS own the affordance (arm on adopted-wait only via a settled-flag; release via `releasePopinTrigger`, armPopinTrigger's mirror), keeping `consumePreload` loading-state-agnostic — load-bearing for the extract-and-execute test harness, which injects neither `document` nor `loadingState` and calls with ≤3 args (the guard makes the 4th a no-op), and the right factoring anyway (it reports that it settled; callers decide who cares). The ready branch's SYNCHRONOUS settle is equally load-bearing: without it a caller cannot distinguish ready from in-flight and would arm forever — an instant cached open never flashes a busy state. Double-click protection during the wait comes free from EXISTING mechanisms (the entry gates refuse an armed trigger; the browser suppresses a disabled `<button>`), and the failed-adoption leg releases before the click-time fallback re-arms through popinLoad's own path. Playwright note: an armed `<a>` reads [disabled] in the accessibility tree, so Playwright's own actionability check refuses to click it — a second-click scene needs `force: true` (a real pointer is NOT blocked on aria-disabled). e2e `test/e2e/popin-preload-loading-state.spec.js` (5 arms, red-first: 4 red on the pre-fix dist, ready-branch no-arm control green on both). (13) **A free variable inside a `try` turned every successful JSON load into a fabricated server error (#B315, 0.6.x).** `popinLoad`'s readyState-4 2xx branch dispatched `success.<id>` on `$forms[0]` — but the module's sole `var $forms` is declared in `popinBind`, a SIBLING function under `Popin`, so reading it threw `ReferenceError` straight into that same handler's `catch`, which fabricates `{status: 422, error}` and fires `error.<id>`. `success` therefore never reached a subscriber at all. ⚠️ **The observable is worse than a stack trace, and that is the part worth carrying:** the catch overwrites `result.error` with `JSON.parse(xhr.responseText)` whenever the response is `application/json`, and the throwing line is reachable ONLY for JSON (a non-JSON body returns early at the `loaded.` trigger just above it) — so the consumer received a **422 whose payload was the SUCCESSFUL response body**, reading as a server-side failure that never happened. Fix: dispatch on `$el`, matching the two sibling triggers in the same handler (`loaded.`, `error.`) — `popinLoad` is the CONTENT loader, so a JSON response need not involve a form at all; the `$forms[0]` target was copy-paste from a validator context, whose commented-out `handleXhrResponse` line still sits directly above it. Reproduced BEFORE fixing (a replica of the readyState-4 2xx branch with two firing controls: non-JSON → `loaded`, `location` → early return); tests `test/core/popin.test.js §34` (4 — scope pin, single-declaration-plus-position pin, dispatch-target pin, dist-fidelity pin; both source pins validated RED against the pre-fix source, the dist pin RED before the rebuild). ⚠️ **The registry's other two events still never fire (#B315b):** `progress`'s entire `xhr.onprogress` block including its `triggerEvent` is commented out (dead by construction, with its `ontimeout` sibling), and `click` has ZERO `triggerEvent` sites anywhere — the `popin.click.*` identifiers are per-element DOM event names in a different namespace. Both still register cleanly through `on()`, so the surface advertises three events and delivered none; their disposition is a DESIGN question (implement, or drop them from the registry so the surface stops lying) and was deliberately NOT folded into this fix, which was an unambiguous defect. Browser-bundled ⇒ consumers restart AND re-bake.
|
|
904
904
|
|
|
905
905
|
214. **Per-request routing-clone correctness — narrow the defensive clone, and propagate mutations onto returned clones (consolidates former #194/#195; #B52-residual, 2026-06-18).** The router's per-request `options.conf = JSON.clone(conf)` — a multi-MB deep clone of the ENTIRE bundle config per matched request, held for the request's whole query+render window — was narrowed to: shallow-copy the top level + `conf.content`, deep-clone ONLY `conf.content.routing`, the one subtree actually MUTATED per request (audited: every other per-request write is a whole-subtree REASSIGNMENT a shallow copy isolates, and plugins/connectors/models read config via their own clones, never through the request's) — removing a dev/high-load heap high-water-mark proportional to config size (measured 163→62 MB peak under 150 concurrent slow requests, drains on idle). Rule: clone only the subtrees mutated per request and share the immutable remainder by reference — but audit EVERY per-request writer first, or a missed mutate-into-a-shared-subtree reintroduces cross-request contamination. Sibling: `getRouteByUrl` (server-side redirect/relative-route resolution AND browser-bundled) aliased the shared route config's `param` by REFERENCE while the matcher rewrites `param.{path,namespace,file,title}` in place for `:placeholder` routes — request A's substitution contaminated request B (server) and repeat navigations (client). The fix is TWO coordinated edits, not just "clone the alias": clone `param` per request AND propagate the substituted param onto the returned route object — the function returns a fresh clone of the SINGLETON, so cloning alone returns the un-substituted `:placeholder` and regresses even the first request. Rule: when a matcher both aliases a shared config object and returns a re-clone of that object, isolating the alias is insufficient — propagate the per-request mutations onto the returned clone. The sibling is browser-bundled → prod dist rebuild.
|
|
906
906
|
|
|
@@ -924,7 +924,7 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
924
924
|
|
|
925
925
|
239. **Native schema/DTO builder `lib/dto` (`require('gina').dto`) — JSON-Schema-canonical, three projections (#DTO1 primitive, 0.5.18-alpha.2).** Author a data shape ONCE: `dto.object({ email: dto.string().email().required(), age: dto.integer().min(0).max(120), role: dto.enum(['admin','user']).required() }).as('CreateUser')`. Projects to (a) dialect-aware JSON Schema — `.toJsonSchema('draft-07'|'2020-12'[,{standalone}])`, the IDENTITY/canonical form for OpenAPI 3.1 requestBody/responses + MCP inputSchema; (b) `.toRules()` — the LIVE form-validator rules-object (the SAME engine routing `validator::` + `forms/rules/*.json` use, so a DTO unifies validation rather than adding a parallel validator; measured returning `{isValid():fn, error:{field:{rule:msg}}, data:coerced}` with coercion + `exclude` stripping); (c) `.name` — stable id for the type generator + a route's `param.dto`. Curated vocabulary EXCLUDES `toFloat` (server crash, `form-validator.js:1514`) + `query` (spins a Controller). Zero runtime deps, CJS/`var`; named registry on `process.gina._dtos` (survives dev hot-reload — plain-`require`d in `lib/index.js` for stable `instanceof`). **⚠️ VALUE-RANGE (`.min()/.max()`) is SCHEMA-ONLY in this cut** — carried as `minimum`/`maximum` for OpenAPI/MCP fidelity but NOT runtime-enforced: the `is` evaluator (`form-validator.js:1115`) accepts only a single binary comparison (no `&&`, no `this.value`) and a `$field` self-ref CRASHES the server form-body path (`main.js:7166-7172` reads `$fields.count()` on the null server `$fields` — a #B85-sibling), so `toRules()` compiles NO `$` of its own; runtime value-bound = a follow-on (a first-class engine rule, browser-bundled → dist rebuild). **⚠️ But an AUTHORED `$` can still reach the rules** — via an enum VALUE (`dto.enum(['$100'])`), a field NAME, or a date mask — and ANY `$` in the stringified rules sends the engine down that same null-`$fields` crash path (measured), so `toRules()` now THROWS on one (`toJsonSchema()` is deliberately NOT guarded — a `$` is valid in a JSON Schema enum, so a currency-style DTO still documents and still serves as a `param.responseDto`). **⚠️ A required field the client OMITS entirely PASSES** (isRequired fires only on present-but-empty, not a missing key) — a server-side validation driver MUST inject `''` for rule-declared fields absent from the payload before validating. Also un-collapses inline `validator::{...}` routing requirements into real JSON Schema via `lib.routingIntrospect.requirementToSchema()` (pure; the OpenAPI `.*` fix). Consumed by `bundle:openapi`/`bundle:mcp` (#DTO1c) + the default-on #DTO2 422 validation pipe (next). Server-side — NO dist rebuild. Tests: `test/lib/dto.test.js` (37, behavioural-through-the-real-engine + subtract) + `routing-introspect.test.js`.
|
|
926
926
|
|
|
927
|
-
240. **DTOs drive `bundle:openapi` + `bundle:mcp` schemas via a `dtos/<Name>.js` bundle convention (#DTO1c, 0.5.18-alpha.2).** A route declares `param.dto` / `param.responseDto` (names) in `routing.json`; the generators resolve each to `<bundle>/dtos/<name>.js` via `dto.load(bundleSrcPath, name)` — a new `lib.dto` method: require the file → `mod instanceof DtoObject ? mod : mod(dto)` (factory) → stamp+register → registry-fallback → null; fail-soft (a broken factory THROWS, the caller warns+skips), no `require.cache` eviction. **⚠️ A bundle DTO file MUST use the FACTORY shape `module.exports = function (dto) { return dto.object({...}, 'Name'); }`, NOT `require('gina').dto`** — because the OFFLINE `bundle:openapi`/`bundle:mcp` bootstrap via the lib registry (`bin/cli`), NOT `core/gna.js`, so a `require('gina')` inside a DTO file cold-loads gna.js with no bundle context and THROWS at `core/gna.js:174` (`ctxObj.paths` null). `require('gina').dto` works only at request-time in a booted bundle; the factory works in both (measured — the naive shape's offline throw was reproduced, the factory proven end-to-end). openapi (3.1 → JSON Schema 2020-12): `param.dto` on post/put/patch → `requestBody` + a `422`; `param.responseDto` → the 200 `content`. mcp (draft-07): `param.dto` → `inputSchema.body` on non-GET (else the lenient placeholder); `param.responseDto` → `outputSchema` (the runtime MCP server already passes it through). Both also un-collapse an inline `validator::{...}` URL-param requirement into a real schema fragment (the `.*`-collapse fix). **#B201 (0.6.3) closed three forms that un-collapse silently DROPPED:** the scalar `"isString": N` now emits `minLength` (it was array-only, so even the docs' own scalar example produced nothing), and `isInteger`/`isNumber` digit bounds now emit a `description` + a namespaced `x-gina-digitBounds` `{min?,max?}` extension. Those bounds constrain the value's STRING-FORM length (a negative sign counts), not its range, so they are deliberately NOT mapped to `minimum`/`maximum` — wrong for every negative, since digits `[2,4]` admit `[-999,-1] ∪ [10,9999]`, which no single range expresses — nor to `minLength`/`maxLength`, which are string-only keywords a validator ignores on a numeric type, i.e. the schema-level twin of #B198's "a documented bound nothing enforces". Both generators copy fragment keys verbatim, so annotations flow with zero consumer change and bare-`true` rules stay byte-identical. Server-side/CLI-only — NO dist rebuild. Verified end-to-end against the real offline CLI (a scaffold bundle emitted a real requestBody / inputSchema.body / outputSchema / un-collapsed param). Tests: `test/lib/{bundle-openapi,bundle-mcp}.test.js` (source-pins + a source-locked replica through the real dto + introspect) + `dto.test.js §08` (dto.load). **Response-side emission drops `.exclude()`d fields (#B110, 0.5.18-alpha.2):** both response sites emit `toJsonSchema(dialect, { dropExcluded: true })` — an excluded field can never reach the wire (the render transform deletes it before the single stringify), so the 200 `content` / `outputSchema` must not advertise it in `properties` NOR `required[]` (an emptied `required` is deleted); request-side `requestBody`/`inputSchema.body` keep the declared shape (the client DOES send it). The drop reads the SHAPE (`_excluded`), never `toRules()` (stays total for an authored-`$` DTO), and the render-json dev missing-required warn checks the same response projection — a `.required().exclude()` field an action omits no longer warns. Tests: `dto.test.js §11` + `bundle-openapi.test.js 01.7/02.9` + `bundle-mcp.test.js 02.9` + `render-json.test.js §06`. **#MS4 (2026-07-24, unreleased) adds the authorization contract to `bundle:openapi`:** gated routes (`param.requireAuth === true`, a non-empty `param.roles`, or a non-empty `param.policy` — the exact runtime authz-gate predicate; a truthy-STRING `requireAuth` does not gate, matching the runtime) emit a `401` response entry (+ a `403` only when roles/policy add authorization beyond authentication — role/policy NAMES never reach the spec, mirroring the runtime's generic 403 bodies and client-map stripping), and when machine-caller auth is EFFECTIVELY configured (`auth.machine.enabled === true` strictly AND callers non-empty OR authenticator set — fail-closed, a `"true"` string emits nothing) the spec gains `components.securitySchemes.bearerAuth` (`http`/`bearer` — matching the machine 401's own `WWW-Authenticate: Bearer` challenge; its description notes a custom authenticator may accept other credential shapes and that a signed-in session also satisfies gated routes) + per-operation `security: [{bearerAuth: []}]` on gated routes only, never a top-level `security`. NO session-cookie scheme: the cookie name is app-owned (set in app code, unreadable offline) — session-only gated routes carry the credential story in the 401 description instead. Enabler: the previously-dormant settings.json read switched from a plain `require` (throws on the comment lines real settings.json files carry, silently leaving `settings` null) to `requireJSON` + a parse-failure warn. An un-gated, un-configured bundle's spec is byte-unchanged. Tests: `bundle-openapi.test.js` §03-§04 (source pins + executing the shipped helper bytes via control-gated extraction, incl. the role-name-absence and truthy-string-fail-closed cases). **The contextless-`require('gina')` boundary is INTENDED but was previously unrecoverable and undiagnosable (#B276+#B277, 0.6.4-alpha.2).** `index.mjs`'s own docblock has always stated it — *"Importing outside a spawned bundle child … the framework bootstrap expects the bundle context and throws without it"* — and both published entries (`.` → `core/gna.js`, `./gna` → the root barrel, each with shipped `.d.ts` + ESM twins) throw alike. Two defects sat on top of that correct boundary. **(1) The throw was uncatchable.** The logger is a load-time singleton (`lib/index.js` requires it eagerly, `main.js` calls `Logger()` at module scope) and its default flows are `['default','mq']`, so `speaker.js` opens a TCP socket BEFORE the boot reaches its throw — and that socket kept the event loop alive, so try/catch caught the error and the process still could not exit. **The hang is LISTENER-CONTINGENT** (measured both ways with two-sided controls): nothing bound to the MQ port ⇒ ECONNREFUSED, handle closes, exit 0 — so the bug is invisible on a quiet machine and hangs with no output on one running bundles or in CI. Fixed by `client.unref()`: a logging transport must never be why a process stays alive. ⚠️ #B318 (0.6.5-alpha.2): a THIRD state existed beyond connected/REFUSED — an UNREACHABLE host (no answer at all), where `unref()` covers the socket HANDLE but not the pending `TCPConnectWrap` REQUEST, which holds the event loop by itself for the OS connect timeout (~75s on macOS), so any fresh-process require of framework code hung that long. Fixed with an unref'd connect deadline (2s default, `opt.mqConnectTimeout` override) that `destroy(err)`s only while `client.connecting`, cleared on both settle paths, erroring through the existing handler (same callback contract, no new output path). Discriminating instrument for this class: `process._getActiveRequests()` reports `TCPConnectWrap` while `_getActiveHandles()` shows only the unref'd Socket. ⚠️ **The socket still CONNECTS — `unref` changes only whether it votes on process lifetime** (consumer-verified 2026-08-05 against a live listener: the `[MQSpeaker] connected to server` line still logs, and `getActiveResourcesInfo()` shows NO `TCPSocketWrap` at 600ms after the connect demonstrably completed). So anyone counting inbound connections on the MQ side still sees them; "unref'd" is not "suppressed". Blast radius measured near-zero — 113/137 CLI handler files already `process.exit()` (so they truncate flushes today regardless), a booted bundle holds the loop via its own listener (`core/server.js` owns the single `listen()` for both engines), and the file container's delayed-output path is opt-in with its own client. ⚠️ The consumer-visible workaround `GINA_LOG_STDOUT=true` does splice `mq` out of the flows, but it ALSO flips log format to JSON (measured) — a container logging-mode flag pressed into service as a "don't open a socket" switch. **(2) The message named an internal call, not the boundary** — `setPath("gina.home", path): path cannot be empty or undefined`, because `getEnvVar` reads `process.gina` ONLY (never `process.env`, by design) and is empty outside the CLI. It now names the boundary and routes the reader to `SuperController.createTestInstance()`, which IS reachable outside a bundle (`core/controller` loads standalone) and is the supported way to exercise controller code without booting. ⛔ **Do NOT "fix" this by extending the `getEnvVar → process.env` ladder to `gna.js`, nor by skipping the `setPath` when empty** — REFUTED BY MEASUREMENT: satisfying that call merely defers the failure 8 lines to `ReferenceError: GINA_HOMEDIR is not defined` (a bare global), strictly less legible, with `projects[projectName].path` failing next; and that `setPath` is the SOLE registration site for `gina.home` repo-wide (the SQLite connector and session store read it), so skipping it leaves it unregistered. Failing fast at the first point contextless boot is detectable is correct. Tests: `test/lib/logger-mq-speaker-unref.test.js` (comment-stripped source pin + a behavioural arm driven against an isolated `GINA_HOMEDIR` and an ephemeral listener, carrying its own must-be-killed control; the pin is comment-stripped because subtracting the fix left a bare existence pin GREEN against `// client.unref();`).
|
|
927
|
+
240. **DTOs drive `bundle:openapi` + `bundle:mcp` schemas via a `dtos/<Name>.js` bundle convention (#DTO1c, 0.5.18-alpha.2).** A route declares `param.dto` / `param.responseDto` (names) in `routing.json`; the generators resolve each to `<bundle>/dtos/<name>.js` via `dto.load(bundleSrcPath, name)` — a new `lib.dto` method: require the file → `mod instanceof DtoObject ? mod : mod(dto)` (factory) → stamp+register → registry-fallback → null; fail-soft (a broken factory THROWS, the caller warns+skips), no `require.cache` eviction. **⚠️ A bundle DTO file MUST use the FACTORY shape `module.exports = function (dto) { return dto.object({...}, 'Name'); }`, NOT `require('gina').dto`** — because the OFFLINE `bundle:openapi`/`bundle:mcp` bootstrap via the lib registry (`bin/cli`), NOT `core/gna.js`, so a `require('gina')` inside a DTO file cold-loads gna.js with no bundle context and THROWS at `core/gna.js:174` (`ctxObj.paths` null). `require('gina').dto` works only at request-time in a booted bundle; the factory works in both (measured — the naive shape's offline throw was reproduced, the factory proven end-to-end). openapi (3.1 → JSON Schema 2020-12): `param.dto` on post/put/patch → `requestBody` + a `422`; `param.responseDto` → the 200 `content`. mcp (draft-07): `param.dto` → `inputSchema.body` on non-GET (else the lenient placeholder); `param.responseDto` → `outputSchema` (the runtime MCP server already passes it through). Both also un-collapse an inline `validator::{...}` URL-param requirement into a real schema fragment (the `.*`-collapse fix). **#B201 (0.6.3) closed three forms that un-collapse silently DROPPED:** the scalar `"isString": N` now emits `minLength` (it was array-only, so even the docs' own scalar example produced nothing), and `isInteger`/`isNumber` digit bounds now emit a `description` + a namespaced `x-gina-digitBounds` `{min?,max?}` extension. Those bounds constrain the value's STRING-FORM length (a negative sign counts), not its range, so they are deliberately NOT mapped to `minimum`/`maximum` — wrong for every negative, since digits `[2,4]` admit `[-999,-1] ∪ [10,9999]`, which no single range expresses — nor to `minLength`/`maxLength`, which are string-only keywords a validator ignores on a numeric type, i.e. the schema-level twin of #B198's "a documented bound nothing enforces". Both generators copy fragment keys verbatim, so annotations flow with zero consumer change and bare-`true` rules stay byte-identical. Server-side/CLI-only — NO dist rebuild. Verified end-to-end against the real offline CLI (a scaffold bundle emitted a real requestBody / inputSchema.body / outputSchema / un-collapsed param). Tests: `test/lib/{bundle-openapi,bundle-mcp}.test.js` (source-pins + a source-locked replica through the real dto + introspect) + `dto.test.js §08` (dto.load). **Response-side emission drops `.exclude()`d fields (#B110, 0.5.18-alpha.2):** both response sites emit `toJsonSchema(dialect, { dropExcluded: true })` — an excluded field can never reach the wire (the render transform deletes it before the single stringify), so the 200 `content` / `outputSchema` must not advertise it in `properties` NOR `required[]` (an emptied `required` is deleted); request-side `requestBody`/`inputSchema.body` keep the declared shape (the client DOES send it). The drop reads the SHAPE (`_excluded`), never `toRules()` (stays total for an authored-`$` DTO), and the render-json dev missing-required warn checks the same response projection — a `.required().exclude()` field an action omits no longer warns. Tests: `dto.test.js §11` + `bundle-openapi.test.js 01.7/02.9` + `bundle-mcp.test.js 02.9` + `render-json.test.js §06`. **#MS4 (2026-07-24, 0.5.25) adds the authorization contract to `bundle:openapi`:** gated routes (`param.requireAuth === true`, a non-empty `param.roles`, or a non-empty `param.policy` — the exact runtime authz-gate predicate; a truthy-STRING `requireAuth` does not gate, matching the runtime) emit a `401` response entry (+ a `403` only when roles/policy add authorization beyond authentication — role/policy NAMES never reach the spec, mirroring the runtime's generic 403 bodies and client-map stripping), and when machine-caller auth is EFFECTIVELY configured (`auth.machine.enabled === true` strictly AND callers non-empty OR authenticator set — fail-closed, a `"true"` string emits nothing) the spec gains `components.securitySchemes.bearerAuth` (`http`/`bearer` — matching the machine 401's own `WWW-Authenticate: Bearer` challenge; its description notes a custom authenticator may accept other credential shapes and that a signed-in session also satisfies gated routes) + per-operation `security: [{bearerAuth: []}]` on gated routes only, never a top-level `security`. NO session-cookie scheme: the cookie name is app-owned (set in app code, unreadable offline) — session-only gated routes carry the credential story in the 401 description instead. Enabler: the previously-dormant settings.json read switched from a plain `require` (throws on the comment lines real settings.json files carry, silently leaving `settings` null) to `requireJSON` + a parse-failure warn. An un-gated, un-configured bundle's spec is byte-unchanged. Tests: `bundle-openapi.test.js` §03-§04 (source pins + executing the shipped helper bytes via control-gated extraction, incl. the role-name-absence and truthy-string-fail-closed cases). **The contextless-`require('gina')` boundary is INTENDED but was previously unrecoverable and undiagnosable (#B276+#B277, 0.6.4-alpha.2).** `index.mjs`'s own docblock has always stated it — *"Importing outside a spawned bundle child … the framework bootstrap expects the bundle context and throws without it"* — and both published entries (`.` → `core/gna.js`, `./gna` → the root barrel, each with shipped `.d.ts` + ESM twins) throw alike. Two defects sat on top of that correct boundary. **(1) The throw was uncatchable.** The logger is a load-time singleton (`lib/index.js` requires it eagerly, `main.js` calls `Logger()` at module scope) and its default flows are `['default','mq']`, so `speaker.js` opens a TCP socket BEFORE the boot reaches its throw — and that socket kept the event loop alive, so try/catch caught the error and the process still could not exit. **The hang is LISTENER-CONTINGENT** (measured both ways with two-sided controls): nothing bound to the MQ port ⇒ ECONNREFUSED, handle closes, exit 0 — so the bug is invisible on a quiet machine and hangs with no output on one running bundles or in CI. Fixed by `client.unref()`: a logging transport must never be why a process stays alive. ⚠️ #B318 (0.6.5-alpha.2): a THIRD state existed beyond connected/REFUSED — an UNREACHABLE host (no answer at all), where `unref()` covers the socket HANDLE but not the pending `TCPConnectWrap` REQUEST, which holds the event loop by itself for the OS connect timeout (~75s on macOS), so any fresh-process require of framework code hung that long. Fixed with an unref'd connect deadline (2s default, `opt.mqConnectTimeout` override) that `destroy(err)`s only while `client.connecting`, cleared on both settle paths, erroring through the existing handler (same callback contract, no new output path). ⚠️ **That safety claim — "only while connecting" — is MEASURED FALSE, and it shipped a HIGH regression for one release (#B323, fixed post-0.6.5).** `client.connecting` does NOT flip when the kernel completes the connect; it flips when the POLL phase runs `afterConnect`. A timer fires in the TIMERS phase, which precedes poll — so on any boot that blocks the event loop past the deadline (a bundle mount off a network filesystem, i.e. every Kubernetes-class start) the loop resumes, runs the OVERDUE deadline first, reads `connecting === true` on a connection the kernel established seconds ago, and destroys it. The bundle then logged NOWHERE for the rest of its life, because the speaker dialled exactly once, ever — no error after the first, and nothing in the logs to say so. Measured both directions on the shipped mechanism: a kernel-completed dial reads `connecting` TRUE at the timers phase and FALSE at the check phase; a black-holed one reads TRUE at both (which is what makes the deferral a measurement rather than a hope). The fix defers the verdict one phase — `setImmediate` INSIDE the deadline, so the read happens in the CHECK phase, after poll — and a completed connect survives while a genuinely pending one still dies on schedule, leaving #B318's no-hang contract intact. **The speaker also RECONNECTS now.** `startMQSpeaker` returns a stable `{write}` transport FACADE instead of the socket: `mq/index.js` is the sole consumer and captures that return ONCE, at logger-construction time, holding it for the life of the process — without the indirection a replaced socket is simply unreachable. Each dial builds its OWN `clientOptions` (the listener mints a fresh `sessionId` per connection and the handshake only fires while `clientOptions.sessionId` is unset, so a shared object would make every reconnect skip its acknowledgement), and `close` on the CURRENT socket — a `current !== client` guard keeps a superseded one from stacking a second connection — arms a capped unref'd backoff (`min(500 * 2^n, 30000)`, reset on connect). Unref'd is load-bearing in both directions: a short-lived CLI still exits (#B276 preserved) while a long-lived bundle keeps retrying at a ≤30s cadence and HEALS on its own if a listener appears later. The caller callback settles ONCE (first outcome only), the warn fires once per OUTAGE rather than once per retry (a bare retry warn would emit every 30s forever on a daemonless boot), and frames are DROPPED while down — an unbounded queue behind an outage of unknown length is a memory leak, and the `default`/stdout flow carries the same lines regardless. Generalisable beyond this socket: **a state flag written by an event-loop callback cannot be read from an EARLIER phase and treated as current** — `connecting`, `destroyed`, `readyState` and every `pending*` counter share the property, and a blocked loop is precisely what makes the stale read reachable, so a timers-phase guard over I/O state wants a `setImmediate` (or the event itself) rather than a direct read. Tests: `test/lib/logger-mq-speaker-resilience.test.js` — §01 source pins on the deferral ordering and the reconnect shape, §02 a child that blocks 3s right after the dial and must still deliver, §03 a listener destroyed and rebound on the same port that must be heard again, §04 the no-listener child still exiting promptly; all red-first validated against the pre-fix bytes, with §02 losing its frame there while the connection was ESTABLISHED (`conns=1`), which is the defect's own signature. Discriminating instrument for this class: `process._getActiveRequests()` reports `TCPConnectWrap` while `_getActiveHandles()` shows only the unref'd Socket. ⚠️ **The socket still CONNECTS — `unref` changes only whether it votes on process lifetime** (consumer-verified 2026-08-05 against a live listener: the `[MQSpeaker] connected to server` line still logs, and `getActiveResourcesInfo()` shows NO `TCPSocketWrap` at 600ms after the connect demonstrably completed). So anyone counting inbound connections on the MQ side still sees them; "unref'd" is not "suppressed". Blast radius measured near-zero — 113/137 CLI handler files already `process.exit()` (so they truncate flushes today regardless), a booted bundle holds the loop via its own listener (`core/server.js` owns the single `listen()` for both engines), and the file container's delayed-output path is opt-in with its own client. ⚠️ The consumer-visible workaround `GINA_LOG_STDOUT=true` does splice `mq` out of the flows, but it ALSO flips log format to JSON (measured) — a container logging-mode flag pressed into service as a "don't open a socket" switch. **(2) The message named an internal call, not the boundary** — `setPath("gina.home", path): path cannot be empty or undefined`, because `getEnvVar` reads `process.gina` ONLY (never `process.env`, by design) and is empty outside the CLI. It now names the boundary and routes the reader to `SuperController.createTestInstance()`, which IS reachable outside a bundle (`core/controller` loads standalone) and is the supported way to exercise controller code without booting. ⛔ **Do NOT "fix" this by extending the `getEnvVar → process.env` ladder to `gna.js`, nor by skipping the `setPath` when empty** — REFUTED BY MEASUREMENT: satisfying that call merely defers the failure 8 lines to `ReferenceError: GINA_HOMEDIR is not defined` (a bare global), strictly less legible, with `projects[projectName].path` failing next; and that `setPath` is the SOLE registration site for `gina.home` repo-wide (the SQLite connector and session store read it), so skipping it leaves it unregistered. Failing fast at the first point contextless boot is detectable is correct. Tests: `test/lib/logger-mq-speaker-unref.test.js` (comment-stripped source pin + a behavioural arm driven against an isolated `GINA_HOMEDIR` and an ephemeral listener, carrying its own must-be-killed control; the pin is comment-stripped because subtracting the fix left a bare existence pin GREEN against `// client.unref();`).
|
|
928
928
|
|
|
929
929
|
241. **`helpers/context.js` `getLib`/`getConfig` no longer crash with `Cannot read properties of undefined (reading 'conf')` on a PARTIAL Config (module-load / daemon-spawned bootstrap) — the resolver-layer sibling of the #236 detached-`throwError` fix (0.5.18-alpha.2).** `core/config.js` populates `envConf` (`Env.load`, `:391`) BEFORE `bundlesConfiguration` (assigned at `:243-250` only after `loadBundlesConfiguration` succeeds), and `bundlesConfiguration.conf` is an ALIAS of `envConf` (`:247` `conf: self.getInstance()`). When the async Config build ABORTS before completing — `config.js:223 if (err) { …; setTimeout(() => process.exit(1), 0); return; }` returns BEFORE the `:243` assignment (canonically a fail-closed `secrets.resolve` throws on an unset `${secret:KEY}` at `:2811`) — a detached caller (a bundle `onInitialize`, a cron, a bootstrap `getLib()`) observes a PARTIAL Config: `envConf` valid, `bundlesConfiguration` + `Config.initialized` undefined. `getLib` (`:600`) and both `getConfig` branches (`:465` with-confName, `:478` without) dereferenced `conf.bundlesConfiguration.conf[bundle][env]…` UNCONDITIONALLY there → the `reading 'conf'` TypeError → the #236 detached-path `throwError` re-threw → uncaughtException → SIGKILL, MASKING the real boot error. **Grep-PROVEN surface (not whack-a-mole):** a framework-wide grep of `bundlesConfiguration` reads (`core/ helpers/ lib/`, excl. dist) — the ONLY unguarded live consumers are `getLib` + `getConfig`; `config.js`'s own reads are guarded (`:3140 if (Config.initialized && self.bundlesConfiguration)`) or during-build; `lib/routing/src/main.js:1075` is commented-out (dead); 14/16 derefs are `.conf` (=== envConf). **Fix:** ONE shared `resolveBundlesConf(conf)` (`bundlesConfiguration.conf` when built, else the equivalent `envConf`, else null) consumed by `getLib` + BOTH `getConfig` branches so the two siblings cannot drift; PLUS fail-clean — `getConfig`'s two catches, `getLib`'s outer catch and its unresolvable-else all pass `throwError(…, isFatal=true)` (emerg-log + return, never a bare throw that escalates to SIGKILL). Byte-identical on the healthy path (bundlesConfiguration present); only the previously-crashing partial-Config path changes. **The partial Config is usually a SYMPTOM** — something upstream aborted the build (canonically a fail-closed `${secret:KEY}` resolution) — so hardening the resolvers UN-MASKS that real, actionable error (e.g. `Secret resolution failed for <KEY>`) instead of hiding it behind a `reading 'conf'` crash; the abort itself (e.g. secrets not wired for a deploy) is the caller's config to fix. Rule: a global config resolver reachable from a detached / module-load context must accept a partial Config (bundlesConfiguration may not be built yet; fall back to the aliasing envConf) and fail clean when neither source is usable. Server-side only — NO dist rebuild. Tests: `test/core/context-config-resolution.test.js` (source pins on the shared resolver + both resolvers routed through it + isFatal catches; full/partial/broken replicas; a subtract reproducing the exact crash pre-fix). Commit `b2dc67da`, established 2026-07-14.
|
|
930
930
|
|
|
@@ -942,25 +942,25 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
942
942
|
|
|
943
943
|
250. **The module-level `gina.emit` (`require('gina').emit`) is an inert stub — always returns `false`, never dispatches, never throws; the module object is a plain literal with NO `on`/`once`, so it is not an event surface — application events go through the controller's `self.emitEvent()` (#B109, fixed 2026-07-16).** Pre-fix it was a DETACHED copy of the internal lifecycle emitter's `emit` (`this` at call time = the plain module object, no `_events`): it returned false for every name EXCEPT `'error'`, which THREW its argument via Node's unhandled-`'error'` path (ERR_UNHANDLED_ERROR when called bare) — a synchronous crash landmine on the most guessable call of an error-reporting-shaped API. Binding it instead (`e.emit.bind(e)`) was REJECTED by consumer survey: zero callers existed anywhere (framework, services, scaffolding boilerplate, every known consumer), and binding would have opened dispatch INTO the framework's internal lifecycle listeners (a consumer `emit('complete')` would re-run the server-start continuation with a bogus instance; `emit('error')` would invoke the framework error handler with undefined request/response). Rule: a detached EventEmitter method copy (`x.emit = emitter.emit`) is never a working event API — `this` rebinds at the call site; either `.bind()` it deliberately (only with a listener surface and real demand) or stub it inert.
|
|
944
944
|
|
|
945
|
-
251. **Route authorization gate — `param.requireAuth` / `param.roles` / `param.policy` (#COMPLY1 slices 1-3, 2026-07-16/17, unreleased).** A route requires an authenticated session by declaring `"requireAuth": true` in its routing.json `param` block; `lib/authz-gate` (plain-required in lib/index.js, the dtoPipe precedent) enforces it at BOTH `core/router.js` dispatch sites immediately BEFORE `dtoPipe.validateRequestPayload` — the invariant is **401/403 → 422 → controller** (an unauthenticated caller must never learn whether its payload would have validated; a 422 field map is a disclosure). Strict NO-OP for any route that declares nothing. **Contract: authenticated ⇔ `req.session.user` truthy** — read DIRECTLY, never through router.js's conditionally-installed Passport `request.isAuthenticated()` shim (zero framework callers, measured; it itself converges onto `request.session.user`). **The placement decision that dissolved the dist rebuild:** `param.requireAuth`, NOT a top-level csrfExempt-style flag. `param` rides the existing whole-param clone on both cold (`server.js` `param:JSON.clone(routing[name].param)`) and warm (`getCached`) paths, so a new `param.*` key needs ZERO `lib/routing` edits — measured empty dist-pathspec status, `lib/routing` untouched, no bundle-freshness exposure (the `param.dto` precedent; the top-level `csrfExempt` shape would have cost the two-site propagation edit + a prod dist rebuild because `lib/routing` is browser-bundled). Rule: a new per-route flag belongs in `param` unless it genuinely needs to be top-level (COMPLY6 `rateLimit` should default the same). **On-fail:** 401 `throwError({status:401,error:'Authentication required'})` by default; for a BROWSER navigation with a configured `settings.json > auth.loginRoute` AND an existing `req.session`, a hand-emitted login bounce — NOT `controller.redirect()`, which defaults to a **cacheable 301** (`req.routing.param.code || 301`) with a dev-or-proxied-GATED no-store (#B68), i.e. a login LOOP on a direct-prod deployment (the browser replays the 301 for the later authenticated visit). The gate emits it directly, mirroring redirect()'s exit (`res.writeHead(code,headInfos)` on both engines + `res.end(JSON.stringify({status,headers}))` — the body the inter-bundle `query()` 3xx intercept parses — + the `console.info('<METHOD> [302] <path>')` pod-log line) with the status FORCED to 302 and the no-store set UNCONDITIONAL. The request is `pauseRequest`-snapshotted first so the login action replays it via `self.resumeRequest()`. **The bounce needs all THREE** (else 401): a configured target; NOT an XHR (`req.isXMLRequest` — an XHR follows a Location transparently and would get the login PAGE as its body; measured a bundle with no Session plugin → 401s, never bounces because pauseRequest 424s without a session). **Boot-resolve + fail-fast lint** in `core/server.js` (the `_dtos`/`_adminAllowList` mould): `process.gina._authConf = {loginRoute}` resolved ONCE (O(1) request read, no per-request config clone an unauth caller could amplify); a non-boolean `requireAuth` (a truthy STRING is the motivating case — the gate tests `=== true`, so `"requireAuth":"true"` would silently NOT gate), a non-object `auth`, an unknown/multi-url/parameterized `loginRoute` all REFUSE TO BOOT (#B57 shape). **Two traps a unit test could not catch, caught by the live daemonless boot:** (a) `core/config.js:2086-2091` re-keys every rule to `<rule.toLowerCase()>@<bundle>` at normalisation — a lookup of the declared name (`login`) misses, must be `login@web` (the resolve accepts both); (b) `config.js:2138` already composes the bundle webroot INTO `routing[rule].url`, so a rule-name loginRoute resolves to the real served path for free (`login` → `/web/login`), while an absolute path is verbatim — either way root-relative ⇒ same-origin ⇒ clear of the #B65/#B66/#B67 proxy-host family. Server-side only (no dist rebuild); `settings.json > auth` change needs a bundle restart (boot config). Live-verified matrix (ungated 200 / browser-bounce 302+no-store / snapshot / signed-in 200 / XHR 401 / no-Session-plugin 401 / bad-loginRoute boot-emerg). Slice 2 adds `param.roles: ["admin","editor"]` — ANY-of match against `req.session.user.roles` (opaque strings, no framework vocabulary, no role→permission indirection in v1), which IMPLIES requireAuth (evaluation order authN → roles: unauthenticated → the 401/bounce, authenticated-but-role-less → a 403 whose body stays GENERIC — the required roles are never echoed to the wire; the denial detail goes to a server-side console.debug line naming the rule). The boot lint refuses every declared-but-invalid `roles` shape (null / bare string / empty array / non-string or empty-string members — each would otherwise be silently ungated, the truthy-string class). And the boot-built client routing maps (`/_gina/assets/routing.json` — the full map AND the #B66 host-stripped variant, which is cloned FROM the full map AFTER the strip so both inherit it) no longer ship `requireAuth`/`roles`/`policy` to the browser: they name the authorization model, and the client bundle reads none of them (measured: zero readers in src and in the built artifacts). **#B212 (2026-08-02, unreleased): the maps are built ONCE, engine-agnostically, by `buildClientRoutingAssets` in `core/server.js` and served under EVERY engine** — they used to be built and served by the isaac engine only, so an `engine: "express"` bundle answered the framework 404 for the URL the browser fetches at boot (`core.js` fetches it to populate the client routing table and, lacking a `response.ok` check, installed that 404 JSON body AS the table — the client-side guard is tracked separately as #B213). isaac consumes the pre-built maps via `serverOpt.clientRoutingAssets` for its precompressed-file fast-path (byte-identical serve, md5-measured across engines on one fixture); the engine-agnostic `onRequest` handler mirrors the isaac header set with a per-request #B65-twin proxied classification picking the #B66 variant. **Slice 3 (#SPA1, 2026-08-02, unreleased, operator-gated) rebuilt the cut as an ALLOWLIST with derived flags** — the old denylist meant every future route key shipped to anonymous browsers by default (schema/routing.json is `additionalProperties: true` at both levels; that is how `csrfExempt`, `param.dto` and `queryTimeout` reached the wire). The served map now carries ONLY the measured client contract: `url`/`method`/`webroot`/`bundle`/`hostname`(+`host` raw-only, per #B66), the `negotiate` capability flag, plain-regex `requirements` entries (`validator::` bodies withheld), URL-placeholder `param` bindings (a param entry ships iff the route's own url declares `:key`), and a derived `isRedirect` boolean replacing `param.control` (client `toUrl` reads `isRedirect === true` OR the server-side `param.control`, so both sides work; the `this.param &&` guard also fixed a latent throw on param-less routes). Dispatch keys (`param.control`/`file`/`path`/`title`), `namespace`, `scopes`, `cache` config, `csrfExempt`, `middlewareIgnored` are withheld — a DISCLOSED behaviour change; consumer code reading other keys off `gina.config.routing` must move server-side. Cross-bundle client `getRoute('rule@bundle')`/`toUrl` is measured-unaffected (the global map stays global — bundle-scoping was measured to break production cross-bundle form-rule query URLs and REVERSED at the gate). Delivery: per-variant weak ETag + `cache-control: no-cache` on BOTH engines (was `max-age=86400`) — one conditional GET (normally 304) per page boot closes the 24h post-restart staleness window; #B213 adds the client's missing `response.ok` guard so a non-2xx body can never again be installed as the routing table. **Slice 3 adds `param.policy: "<name>"`** — the record/attribute escape hatch RBAC cannot express (ownership etc.): `<bundle>/policies/<name>.js` exporting `module.exports = function (user, req) { return boolean; }`, registered at BOOT via `lib.authzGate.registerPolicy(bundleSrcPath, name)` (the `lib.dto.load` mould — returns null when unresolved, THROWS when present-but-broken, caller adds route context) onto `process.gina._policies`, so the request path is an O(1) lookup — no fs, no re-require (which would also feed the dev-mode `module.children` leak class #B32). IMPLIES requireAuth and AND-composes AFTER roles (**authN → roles → policy**; a role denial never runs the policy). **The signature is SYNCHRONOUS because the GATE is** (`authorizeRequest` returns a boolean both `router.js` sites branch on directly), and TWO rules make that safe rather than merely convenient — the measured asymmetry is the whole design: a native `async function` policy reads `constructor.name === 'AsyncFunction'` (the ONLY boot-visible tell — `typeof` says `'function'` for both, measured) so it is **refused at boot**; but a policy that merely RETURNS a promise (the transpiled-async shape) reads `constructor.name === 'Function'` and is **boot-INVISIBLE**, so the allow test is strictly `=== true` — a promise is truthy but not `true`, and a **truthy-allow gate would ALLOW it unconditionally: the quietly-OFF class INVERTED into fail-OPEN** (live-measured: a promise-returning policy whose answer was `true` boots fine and then 403s every request, warning ONCE; a `!!allowed` gate would have allowed a request the policy explicitly DENIED). A non-boolean return warns once per policy naming it (a per-request warn would bury the message it exists to surface); a THROWING policy denies 403 + `console.error` and never 500s the wire nor kills the bundle. Every slice-3 denial keeps the generic 403 — the **policy name never reaches the wire** (measured live: 0 occurrences of the policy name, the rule name, or even the policy's own thrown error text in the 403 body, against a `Forbidden`-fires-twice control); the detail goes to the server-side `[ authz ] denied \`<rule>\` — policy \`<name>\` did not allow|threw` line. Boot lint refuses a non-string/empty `policy`, a missing file, a non-function export and an async policy (#B57 shape) — all four live-verified `exit=1`. `self.hasRole(role)` is the imperative escape hatch for an action authorizing mid-logic: it **delegates to the gate's exported `hasAnyRole`** rather than re-reading `user.roles` itself (one definition of "holding a role" framework-wide — the byte-identical-copy-paste class `lib/cmd-status-format`/`lib/json-config-header` were extracted to end) and carries the #B35 released-response guard (`local.req == null` → false); its `types/index.d.ts` SuperController member is MANDATORY — the #DTO3b parity gate diffs the interface against a real instance. Server-side only, no dist rebuild (slice 2 already strips `policy` from the client blobs). Tests 65→110 (`test/lib/authz-gate.test.js` §11-§13; the roles-before-policy ordering pin + the delegation pin both validated can-fail against verified-changed perturbed copies). **#MS3 (2026-07-24, unreleased) adds MACHINE-CALLER authentication to the same gate** — service-to-service callers that cannot hold a session: `settings.json > auth.machine` (STRICTLY opt-in; `enabled` boolean-linted, fail-closed) declares named callers (`"callers": { "<name>": { "key": "<${secret:KEY}-capable>", "roles": [...] } }`); a GATED session-less request presenting `Authorization: Bearer <key>` is verified against a BOOT-precomputed sha256 hash per caller (on `process.gina._authConf.machine` — the request path compares fixed-length digests via `crypto.timingSafeEqual`: hash-BOTH-sides so no length oracle, raw keys not retained past boot, an invalid token pays the full caller scan) and becomes the effective principal `{ name, roles, machine: true }` stamped on `req.machineCaller` — it satisfies `requireAuth`, its configured roles ride the SAME ANY-of match, policies receive it as their `user` argument (discriminate via `user.machine === true`), audit actors snapshot `{ key: <callerName>, roles, machine: true }`, and `self.hasRole()` answers its roles. SESSION WINS (a signed-in request never consults the machine path); resolution is LAZY (un-gated routes never pay a compare); `enabled: false` and a pre-#MS3 `_authConf` shape are byte-identical legacy (both subtract-locked). A presented-but-invalid Bearer gets a clean 401 + `WWW-Authenticate: Bearer` and NEVER the login bounce (the '401-machine' audit outcome); a custom-scheme miss degrades to the ordinary 401/bounce — the machine 401 stays Bearer-specific. **Deliberately NO per-route opt-in key**: per-route granularity rides `roles` (keep a route human-only by requiring a role no caller holds, machine-only by one only callers hold). The escape hatch — `auth.machine.authenticator` names `<bundle>/authenticators/<name>.js` (the `policies/<name>.js` shape applied to authN): a SYNCHRONOUS `function (req) { return { name, roles } | null; }` run AFTER the built-in map (first success wins) and REGARDLESS of Bearer presence, so it verifies any sync-checkable credential (a sync-`verify` JWT, an HMAC header, an x-api-key); its return is NORMALIZED (`machine: true` forced, non-array roles collapse to `[]`), an `async function` module REFUSES the boot (the policy asymmetry: the transpiled-async promise shape is boot-invisible, which is why the runtime shape check stays strict), and a throw or malformed return at request time is fail-closed unauthenticated (warn-once), never a 500. `schema/settings.json` gains the previously-undeclared `auth` block (loginRoute + machine). Server-side only, no dist rebuild; `auth.machine` is boot config (restart to change). Tests: `test/lib/authz-gate-machine.test.js` (52 — session-wins + never-bounce decisive fixtures, both subtracts, the hash-of-key non-admission control, map-first spy proof, real-audit machine actors, controller hasRole via createTestInstance). **#COMPLY10 (2026-07-25, unreleased) adds an opt-in DENY-BY-DEFAULT mode to the same gate:** `settings.json > auth.requireAuthByDefault: true` INVERTS the strict-NO-OP above — an un-annotated route is GATED, and `param.public: true` is the explicit exemption. `public` is tested INSIDE the un-annotated branch, so precedence is STRUCTURAL: a route carrying `public` alongside an explicit gate key never reaches that branch and stays gated, i.e. a hand-built or tampered config still fails CLOSED without relying on the lint having run. It is a `param.*` key for the same measured reason `requireAuth` was (rides the whole-param clone on both cold and warm paths ⇒ zero `lib/routing` edits, no dist rebuild — a top-level key would have cost the two-site propagation edit AND a prod rebuild), and a POSITIVE marker rather than a reused `requireAuth: false`, so a reviewer can tell an audited exemption from a leftover (the `csrfExempt` rationale). **The mode is keyed PER BUNDLE** (`_authConf.byBundle`, read via `req.routing.bundle`) — merged mode runs every bundle in ONE process and `_authConf` is written once per `init()`, so a FLAT flag would let whichever bundle booted LAST decide the posture for all of them, silently un-gating a bundle that had opted in (a fail-OPEN; the write now merges into the existing map instead of replacing it). ⚠️ `public` is a strict-mode RESERVED WORD — under the old denylist it could not join the destructuring strip as a bare binding (a SyntaxError) and was dropped with a separate `delete cleanParam.public;`; since the Slice-3 (#SPA1) allowlist rebuild of the #B212 shared builder in `core/server.js`, the withholding is structural (no authorization key is on the roster) and that whole mechanism note is historical. Boot refuses three mode-specific contradictions: `public` + an explicit gate key (same axis, opposite answers, no safe precedence); a login route the mode would gate (it bounces to ITSELF — an infinite redirect that locks out every visitor, so a rule-name target must be marked `public` and an unresolvable absolute path refuses, while NO `loginRoute` only WARNS since a pure API/machine bundle legitimately 401s everything); and **a mode-gated route that also declares `cache`** — both render-cache serve points run BEFORE the gate and `buildKey` is `<release>:<kind>:<bundle>:<url>` with NO principal component, so a cached gated route replays the first authenticated body to every later anonymous caller (scoped to the mode, so an EXPLICITLY gated cached route is not newly refused — that pre-existing exposure is tracked separately). The five framework-injected routes (`webroot@<bundle>` — which also matches `/` under `webrootAutoredirect` — `custom-error-page`, `bundle-status`, and both upload routes) ship `param.public: true`, so flipping the mode cannot take the site root or the error renderer offline. `bundle:openapi` threads the mode into its gated predicate, so the emitted spec can never declare a newly-gated route unauthenticated. Server-side only, no dist rebuild (measured: `authorizeRequest` appears 0× in the built browser artifact against a firing `csrfExempt` control); boot config, restart to change. **#B158 — a GATED route may not also declare `cache`, however it is gated (0.5.26).** Both render-cache serve points run BEFORE the gate — the engine-agnostic read serves and RETURNS ahead of `router.route` (inside which the gate runs), and isaac's runs pre-routing, before `req.routing` exists — and `buildKey` composes `<release>:<kind>:<bundle>:<url>` with NO principal component, so a cached gated route replayed the first authenticated caller's rendered body verbatim to every later anonymous one (reproduced live on an isolated boot: a Bearer-authenticated render, same body and same render nonce, served to an unauthenticated caller, while the identical route WITHOUT `cache` correctly 401'd). The boot lint now REFUSES the pairing in both modes (`auth.requireAuthByDefault` had covered only mode-gated routes; the explicitly annotated case it left open was the pre-existing hole) — **ACTION REQUIRED: such a bundle will not start** until `cache` is dropped or the route opened. Second layer: `lib/authz-gate` exports `isRouteGated(req)` (the one runtime answer — explicit key, else `param.public`, else the per-bundle mode) and all three render delegates consult it inside their `writeCache` guard, so a config that never passed the lint (hand-built, tampered, mutated at runtime) still fails CLOSED. **#B159 — an authorization key on a `method: "ws"` route REFUSES the boot (0.5.26).** A ws route is registered on the engine's extended-CONNECT handler and never reaches `handle()`/`router.route`, so `requireAuth`/`roles`/`policy` on one are unenforceable BY CONSTRUCTION — and it previously linted clean, booted, AND was counted in the `Registered N authorization-gated route(s)` line, so the framework actively CONFIRMED an illusion. Refused now (the truthy-string-`requireAuth` precedent: a structurally-OFF gate must refuse exactly like a silently-OFF one), keyed on `/^ws$/i` — byte-identical to the ws registrar's own predicate so the two can never disagree — placed after the policy block so one check covers all three keys. Remedy: authenticate in the `wsHandler`, which receives the full request. The MODE half is a WARN, not a refusal (an explicit key is an author asserting something false; `requireAuthByDefault` sweeps up every un-annotated route, so refusing would break a working ws bundle the moment the mode is switched on): ws routes are excluded from the deny-by-default tally and NAMED at boot instead. Live-verified both arms (refuses with the key, boots without). Tests: `authz-gate.test.js §17` (8 — replica + case-insensitivity + the `websocket`-is-not-`ws` boundary + source pins) and `§15` (20 — per-bundle isolation, structural precedence, a mode-OFF subtract-control, and a boot-lint replica; all 11 source pins + the headline behaviour red-armed against pre-fix source).
|
|
945
|
+
251. **Route authorization gate — `param.requireAuth` / `param.roles` / `param.policy` (#COMPLY1 slices 1-3, 2026-07-16/17, 0.5.19).** A route requires an authenticated session by declaring `"requireAuth": true` in its routing.json `param` block; `lib/authz-gate` (plain-required in lib/index.js, the dtoPipe precedent) enforces it at BOTH `core/router.js` dispatch sites immediately BEFORE `dtoPipe.validateRequestPayload` — the invariant is **401/403 → 422 → controller** (an unauthenticated caller must never learn whether its payload would have validated; a 422 field map is a disclosure). Strict NO-OP for any route that declares nothing. **Contract: authenticated ⇔ `req.session.user` truthy** — read DIRECTLY, never through router.js's conditionally-installed Passport `request.isAuthenticated()` shim (zero framework callers, measured; it itself converges onto `request.session.user`). **The placement decision that dissolved the dist rebuild:** `param.requireAuth`, NOT a top-level csrfExempt-style flag. `param` rides the existing whole-param clone on both cold (`server.js` `param:JSON.clone(routing[name].param)`) and warm (`getCached`) paths, so a new `param.*` key needs ZERO `lib/routing` edits — measured empty dist-pathspec status, `lib/routing` untouched, no bundle-freshness exposure (the `param.dto` precedent; the top-level `csrfExempt` shape would have cost the two-site propagation edit + a prod dist rebuild because `lib/routing` is browser-bundled). Rule: a new per-route flag belongs in `param` unless it genuinely needs to be top-level (COMPLY6 `rateLimit` should default the same). **On-fail:** 401 `throwError({status:401,error:'Authentication required'})` by default; for a BROWSER navigation with a configured `settings.json > auth.loginRoute` AND an existing `req.session`, a hand-emitted login bounce — NOT `controller.redirect()`, which defaults to a **cacheable 301** (`req.routing.param.code || 301`) with a dev-or-proxied-GATED no-store (#B68), i.e. a login LOOP on a direct-prod deployment (the browser replays the 301 for the later authenticated visit). The gate emits it directly, mirroring redirect()'s exit (`res.writeHead(code,headInfos)` on both engines + `res.end(JSON.stringify({status,headers}))` — the body the inter-bundle `query()` 3xx intercept parses — + the `console.info('<METHOD> [302] <path>')` pod-log line) with the status FORCED to 302 and the no-store set UNCONDITIONAL. The request is `pauseRequest`-snapshotted first so the login action replays it via `self.resumeRequest()`. **The bounce needs all THREE** (else 401): a configured target; NOT an XHR (`req.isXMLRequest` — an XHR follows a Location transparently and would get the login PAGE as its body; measured a bundle with no Session plugin → 401s, never bounces because pauseRequest 424s without a session). **Boot-resolve + fail-fast lint** in `core/server.js` (the `_dtos`/`_adminAllowList` mould): `process.gina._authConf = {loginRoute}` resolved ONCE (O(1) request read, no per-request config clone an unauth caller could amplify); a non-boolean `requireAuth` (a truthy STRING is the motivating case — the gate tests `=== true`, so `"requireAuth":"true"` would silently NOT gate), a non-object `auth`, an unknown/multi-url/parameterized `loginRoute` all REFUSE TO BOOT (#B57 shape). **Two traps a unit test could not catch, caught by the live daemonless boot:** (a) `core/config.js:2086-2091` re-keys every rule to `<rule.toLowerCase()>@<bundle>` at normalisation — a lookup of the declared name (`login`) misses, must be `login@web` (the resolve accepts both); (b) `config.js:2138` already composes the bundle webroot INTO `routing[rule].url`, so a rule-name loginRoute resolves to the real served path for free (`login` → `/web/login`), while an absolute path is verbatim — either way root-relative ⇒ same-origin ⇒ clear of the #B65/#B66/#B67 proxy-host family. Server-side only (no dist rebuild); `settings.json > auth` change needs a bundle restart (boot config). Live-verified matrix (ungated 200 / browser-bounce 302+no-store / snapshot / signed-in 200 / XHR 401 / no-Session-plugin 401 / bad-loginRoute boot-emerg). Slice 2 adds `param.roles: ["admin","editor"]` — ANY-of match against `req.session.user.roles` (opaque strings, no framework vocabulary, no role→permission indirection in v1), which IMPLIES requireAuth (evaluation order authN → roles: unauthenticated → the 401/bounce, authenticated-but-role-less → a 403 whose body stays GENERIC — the required roles are never echoed to the wire; the denial detail goes to a server-side console.debug line naming the rule). The boot lint refuses every declared-but-invalid `roles` shape (null / bare string / empty array / non-string or empty-string members — each would otherwise be silently ungated, the truthy-string class). And the boot-built client routing maps (`/_gina/assets/routing.json` — the full map AND the #B66 host-stripped variant, which is cloned FROM the full map AFTER the strip so both inherit it) no longer ship `requireAuth`/`roles`/`policy` to the browser: they name the authorization model, and the client bundle reads none of them (measured: zero readers in src and in the built artifacts). **#B212 (2026-08-02, 0.6.3): the maps are built ONCE, engine-agnostically, by `buildClientRoutingAssets` in `core/server.js` and served under EVERY engine** — they used to be built and served by the isaac engine only, so an `engine: "express"` bundle answered the framework 404 for the URL the browser fetches at boot (`core.js` fetches it to populate the client routing table and, lacking a `response.ok` check, installed that 404 JSON body AS the table — the client-side guard is tracked separately as #B213). isaac consumes the pre-built maps via `serverOpt.clientRoutingAssets` for its precompressed-file fast-path (byte-identical serve, md5-measured across engines on one fixture); the engine-agnostic `onRequest` handler mirrors the isaac header set with a per-request #B65-twin proxied classification picking the #B66 variant. **Slice 3 (#SPA1, 2026-08-02, 0.6.3, operator-gated) rebuilt the cut as an ALLOWLIST with derived flags** — the old denylist meant every future route key shipped to anonymous browsers by default (schema/routing.json is `additionalProperties: true` at both levels; that is how `csrfExempt`, `param.dto` and `queryTimeout` reached the wire). The served map now carries ONLY the measured client contract: `url`/`method`/`webroot`/`bundle`/`hostname`(+`host` raw-only, per #B66), the `negotiate` capability flag, plain-regex `requirements` entries (`validator::` bodies withheld), URL-placeholder `param` bindings (a param entry ships iff the route's own url declares `:key`), and a derived `isRedirect` boolean replacing `param.control` (client `toUrl` reads `isRedirect === true` OR the server-side `param.control`, so both sides work; the `this.param &&` guard also fixed a latent throw on param-less routes). Dispatch keys (`param.control`/`file`/`path`/`title`), `namespace`, `scopes`, `cache` config, `csrfExempt`, `middlewareIgnored` are withheld — a DISCLOSED behaviour change; consumer code reading other keys off `gina.config.routing` must move server-side. Cross-bundle client `getRoute('rule@bundle')`/`toUrl` is measured-unaffected (the global map stays global — bundle-scoping was measured to break production cross-bundle form-rule query URLs and REVERSED at the gate). Delivery: per-variant weak ETag + `cache-control: no-cache` on BOTH engines (was `max-age=86400`) — one conditional GET (normally 304) per page boot closes the 24h post-restart staleness window; #B213 adds the client's missing `response.ok` guard so a non-2xx body can never again be installed as the routing table. **Slice 3 adds `param.policy: "<name>"`** — the record/attribute escape hatch RBAC cannot express (ownership etc.): `<bundle>/policies/<name>.js` exporting `module.exports = function (user, req) { return boolean; }`, registered at BOOT via `lib.authzGate.registerPolicy(bundleSrcPath, name)` (the `lib.dto.load` mould — returns null when unresolved, THROWS when present-but-broken, caller adds route context) onto `process.gina._policies`, so the request path is an O(1) lookup — no fs, no re-require (which would also feed the dev-mode `module.children` leak class #B32). IMPLIES requireAuth and AND-composes AFTER roles (**authN → roles → policy**; a role denial never runs the policy). **The signature is SYNCHRONOUS because the GATE is** (`authorizeRequest` returns a boolean both `router.js` sites branch on directly), and TWO rules make that safe rather than merely convenient — the measured asymmetry is the whole design: a native `async function` policy reads `constructor.name === 'AsyncFunction'` (the ONLY boot-visible tell — `typeof` says `'function'` for both, measured) so it is **refused at boot**; but a policy that merely RETURNS a promise (the transpiled-async shape) reads `constructor.name === 'Function'` and is **boot-INVISIBLE**, so the allow test is strictly `=== true` — a promise is truthy but not `true`, and a **truthy-allow gate would ALLOW it unconditionally: the quietly-OFF class INVERTED into fail-OPEN** (live-measured: a promise-returning policy whose answer was `true` boots fine and then 403s every request, warning ONCE; a `!!allowed` gate would have allowed a request the policy explicitly DENIED). A non-boolean return warns once per policy naming it (a per-request warn would bury the message it exists to surface); a THROWING policy denies 403 + `console.error` and never 500s the wire nor kills the bundle. Every slice-3 denial keeps the generic 403 — the **policy name never reaches the wire** (measured live: 0 occurrences of the policy name, the rule name, or even the policy's own thrown error text in the 403 body, against a `Forbidden`-fires-twice control); the detail goes to the server-side `[ authz ] denied \`<rule>\` — policy \`<name>\` did not allow|threw` line. Boot lint refuses a non-string/empty `policy`, a missing file, a non-function export and an async policy (#B57 shape) — all four live-verified `exit=1`. `self.hasRole(role)` is the imperative escape hatch for an action authorizing mid-logic: it **delegates to the gate's exported `hasAnyRole`** rather than re-reading `user.roles` itself (one definition of "holding a role" framework-wide — the byte-identical-copy-paste class `lib/cmd-status-format`/`lib/json-config-header` were extracted to end) and carries the #B35 released-response guard (`local.req == null` → false); its `types/index.d.ts` SuperController member is MANDATORY — the #DTO3b parity gate diffs the interface against a real instance. Server-side only, no dist rebuild (slice 2 already strips `policy` from the client blobs). Tests 65→110 (`test/lib/authz-gate.test.js` §11-§13; the roles-before-policy ordering pin + the delegation pin both validated can-fail against verified-changed perturbed copies). **#MS3 (2026-07-24, 0.5.25) adds MACHINE-CALLER authentication to the same gate** — service-to-service callers that cannot hold a session: `settings.json > auth.machine` (STRICTLY opt-in; `enabled` boolean-linted, fail-closed) declares named callers (`"callers": { "<name>": { "key": "<${secret:KEY}-capable>", "roles": [...] } }`); a GATED session-less request presenting `Authorization: Bearer <key>` is verified against a BOOT-precomputed sha256 hash per caller (on `process.gina._authConf.machine` — the request path compares fixed-length digests via `crypto.timingSafeEqual`: hash-BOTH-sides so no length oracle, raw keys not retained past boot, an invalid token pays the full caller scan) and becomes the effective principal `{ name, roles, machine: true }` stamped on `req.machineCaller` — it satisfies `requireAuth`, its configured roles ride the SAME ANY-of match, policies receive it as their `user` argument (discriminate via `user.machine === true`), audit actors snapshot `{ key: <callerName>, roles, machine: true }`, and `self.hasRole()` answers its roles. SESSION WINS (a signed-in request never consults the machine path); resolution is LAZY (un-gated routes never pay a compare); `enabled: false` and a pre-#MS3 `_authConf` shape are byte-identical legacy (both subtract-locked). A presented-but-invalid Bearer gets a clean 401 + `WWW-Authenticate: Bearer` and NEVER the login bounce (the '401-machine' audit outcome); a custom-scheme miss degrades to the ordinary 401/bounce — the machine 401 stays Bearer-specific. **Deliberately NO per-route opt-in key**: per-route granularity rides `roles` (keep a route human-only by requiring a role no caller holds, machine-only by one only callers hold). The escape hatch — `auth.machine.authenticator` names `<bundle>/authenticators/<name>.js` (the `policies/<name>.js` shape applied to authN): a SYNCHRONOUS `function (req) { return { name, roles } | null; }` run AFTER the built-in map (first success wins) and REGARDLESS of Bearer presence, so it verifies any sync-checkable credential (a sync-`verify` JWT, an HMAC header, an x-api-key); its return is NORMALIZED (`machine: true` forced, non-array roles collapse to `[]`), an `async function` module REFUSES the boot (the policy asymmetry: the transpiled-async promise shape is boot-invisible, which is why the runtime shape check stays strict), and a throw or malformed return at request time is fail-closed unauthenticated (warn-once), never a 500. `schema/settings.json` gains the previously-undeclared `auth` block (loginRoute + machine). Server-side only, no dist rebuild; `auth.machine` is boot config (restart to change). Tests: `test/lib/authz-gate-machine.test.js` (52 — session-wins + never-bounce decisive fixtures, both subtracts, the hash-of-key non-admission control, map-first spy proof, real-audit machine actors, controller hasRole via createTestInstance). **#COMPLY10 (2026-07-25, 0.5.26) adds an opt-in DENY-BY-DEFAULT mode to the same gate:** `settings.json > auth.requireAuthByDefault: true` INVERTS the strict-NO-OP above — an un-annotated route is GATED, and `param.public: true` is the explicit exemption. `public` is tested INSIDE the un-annotated branch, so precedence is STRUCTURAL: a route carrying `public` alongside an explicit gate key never reaches that branch and stays gated, i.e. a hand-built or tampered config still fails CLOSED without relying on the lint having run. It is a `param.*` key for the same measured reason `requireAuth` was (rides the whole-param clone on both cold and warm paths ⇒ zero `lib/routing` edits, no dist rebuild — a top-level key would have cost the two-site propagation edit AND a prod rebuild), and a POSITIVE marker rather than a reused `requireAuth: false`, so a reviewer can tell an audited exemption from a leftover (the `csrfExempt` rationale). **The mode is keyed PER BUNDLE** (`_authConf.byBundle`, read via `req.routing.bundle`) — merged mode runs every bundle in ONE process and `_authConf` is written once per `init()`, so a FLAT flag would let whichever bundle booted LAST decide the posture for all of them, silently un-gating a bundle that had opted in (a fail-OPEN; the write now merges into the existing map instead of replacing it). ⚠️ `public` is a strict-mode RESERVED WORD — under the old denylist it could not join the destructuring strip as a bare binding (a SyntaxError) and was dropped with a separate `delete cleanParam.public;`; since the Slice-3 (#SPA1) allowlist rebuild of the #B212 shared builder in `core/server.js`, the withholding is structural (no authorization key is on the roster) and that whole mechanism note is historical. Boot refuses three mode-specific contradictions: `public` + an explicit gate key (same axis, opposite answers, no safe precedence); a login route the mode would gate (it bounces to ITSELF — an infinite redirect that locks out every visitor, so a rule-name target must be marked `public` and an unresolvable absolute path refuses, while NO `loginRoute` only WARNS since a pure API/machine bundle legitimately 401s everything); and **a mode-gated route that also declares `cache`** — both render-cache serve points run BEFORE the gate and `buildKey` is `<release>:<kind>:<bundle>:<url>` with NO principal component, so a cached gated route replays the first authenticated body to every later anonymous caller (scoped to the mode, so an EXPLICITLY gated cached route is not newly refused — that pre-existing exposure is tracked separately). The five framework-injected routes (`webroot@<bundle>` — which also matches `/` under `webrootAutoredirect` — `custom-error-page`, `bundle-status`, and both upload routes) ship `param.public: true`, so flipping the mode cannot take the site root or the error renderer offline. `bundle:openapi` threads the mode into its gated predicate, so the emitted spec can never declare a newly-gated route unauthenticated. Server-side only, no dist rebuild (measured: `authorizeRequest` appears 0× in the built browser artifact against a firing `csrfExempt` control); boot config, restart to change. **#B158 — a GATED route may not also declare `cache`, however it is gated (0.5.26).** Both render-cache serve points run BEFORE the gate — the engine-agnostic read serves and RETURNS ahead of `router.route` (inside which the gate runs), and isaac's runs pre-routing, before `req.routing` exists — and `buildKey` composes `<release>:<kind>:<bundle>:<url>` with NO principal component, so a cached gated route replayed the first authenticated caller's rendered body verbatim to every later anonymous one (reproduced live on an isolated boot: a Bearer-authenticated render, same body and same render nonce, served to an unauthenticated caller, while the identical route WITHOUT `cache` correctly 401'd). The boot lint now REFUSES the pairing in both modes (`auth.requireAuthByDefault` had covered only mode-gated routes; the explicitly annotated case it left open was the pre-existing hole) — **ACTION REQUIRED: such a bundle will not start** until `cache` is dropped or the route opened. Second layer: `lib/authz-gate` exports `isRouteGated(req)` (the one runtime answer — explicit key, else `param.public`, else the per-bundle mode) and all three render delegates consult it inside their `writeCache` guard, so a config that never passed the lint (hand-built, tampered, mutated at runtime) still fails CLOSED. **#B159 — an authorization key on a `method: "ws"` route REFUSES the boot (0.5.26).** A ws route is registered on the engine's extended-CONNECT handler and never reaches `handle()`/`router.route`, so `requireAuth`/`roles`/`policy` on one are unenforceable BY CONSTRUCTION — and it previously linted clean, booted, AND was counted in the `Registered N authorization-gated route(s)` line, so the framework actively CONFIRMED an illusion. Refused now (the truthy-string-`requireAuth` precedent: a structurally-OFF gate must refuse exactly like a silently-OFF one), keyed on `/^ws$/i` — byte-identical to the ws registrar's own predicate so the two can never disagree — placed after the policy block so one check covers all three keys. Remedy: authenticate in the `wsHandler`, which receives the full request. The MODE half is a WARN, not a refusal (an explicit key is an author asserting something false; `requireAuthByDefault` sweeps up every un-annotated route, so refusing would break a working ws bundle the moment the mode is switched on): ws routes are excluded from the deny-by-default tally and NAMED at boot instead. Live-verified both arms (refuses with the key, boots without). Tests: `authz-gate.test.js §17` (8 — replica + case-insensitivity + the `websocket`-is-not-`ws` boundary + source pins) and `§15` (20 — per-bundle isolation, structural precedence, a mode-OFF subtract-control, and a boot-lint replica; all 11 source pins + the headline behaviour red-armed against pre-fix source).
|
|
946
946
|
|
|
947
|
-
252. **Audit trail — `settings.json > audit` + `self.audit()` + authz auto-events (#COMPLY2 slices 1-2, 2026-07-17, unreleased).** A user-attributed append-only record of "who did what to which record when", DISTINCT from `lib/logger` (its own store; never the log sinks). Opt in with `audit.enabled: true` (STRICTLY boolean — a truthy `"true"` refuses to boot, the quietly-OFF class: a compliance control that silently does nothing is worse than one that is absent) and call `self.audit(action, data[, cb])`. **Record schema v1:** `{ id, ts, requestId, actor:{key,roles}, action, resource?, meta?, ip, rule, method, bundle, env }`. **The correlation key is the SHARED request id, not a second one:** slice 1 hoisted the existing `request._ginaReqId` stamp OUT of the `_reqCtxLogging` (JSON-log-mode) gate in `server.js` `onInstance` — always-on, both engines, **plus a first-seer `if (!request._ginaReqId)` guard** the gated stamp never had (it sits in the isaac double-dispatch spot ⇒ a re-entered dispatch REGENERATED the uuid; the hoist closes that latent hazard by construction). The `_reqALS` `.run()` stays JSON-gated, so the deferred always-on-ALS throughput question is untouched. One id, two consumers: audit records and JSON log lines correlate for free, and `_resolveRequestId`'s inbound `X-Request-Id` sanitization is reused — **so the id is client-influenceable BY DESIGN: it is correlation, never attribution** (attribution is the actor fields). **`rule` carries the re-keyed `<rule>@<bundle>` form** (`homepage@web`, `secure@web` — measured live), because `config.js` re-keys every rule at normalisation; a consumer grepping the trail must expect the suffix, not the bare routing.json key (same read as the #OBS1 metrics route label). `ip` is the socket remoteAddress, `::ffff:`-normalized — **`X-Forwarded-For` is NEVER read** (attacker-writable, the #OBS1 rule). `actor` is a SNAPSHOT of `session.user[audit.actorKey]` (default `"id"`) + a COPY of `user.roles`, never the whole user object (PII). **Default backend = append-only JSONL file**, path DERIVED as `<projectPath>/logs/audit-<bundle>-<env>.jsonl` — NOT read from `logsPath`, which is a **measured DEAD placeholder**: `env.json` declares it at the file ROOT, outside the `${bundle}`/`${env}` subtree config.js copies, nothing ever assigns it, so `${logsPath}` resolves to the literal string `"undefined"` and has zero template consumers (`projectPath` is inside the propagated subtree and provably resolves — it is what the live `bundlesPath` is built from). **Per-ENV filename is load-bearing:** two envs of one bundle running concurrently must never interleave writers into one file (harmless for JSONL, FATAL for the slice-3 hash chain). A relative `audit.file` resolves against the project root, never the process cwd (which depends on how the bundle was launched). **Writes are SERIALIZED** (FIFO, one in-flight `fs.write`): parallel writes on one fd complete in threadpool ORDER, and the chain needs on-disk order == emit order, so the queue ships in the MVP rather than with slice 3. `audit.store` (a connectors.json entry, resolved through the `lib/audit-store` dispatcher — the 3rd instance of the job-store mould) is wired from day 1 but **NO connector ships an implementation yet ⇒ configuring it is a clean boot REFUSAL**, never a silent fallback; `store`+`file` together is refused too (mutual exclusion, no silent precedence). **Framework auto-events are ON when audit is on:** the authz-gate deny sites emit `authz.denied` carrying `meta.outcome` (`401`|`login-bounce`|`403-roles`|`403-policy`|`401-machine` — the last added by #MS3's machine deny writer, whose records also carry the machine actor) — the real shape is **3 physical writers / 6 call sites / 5 outcomes** (`denyUnauthenticated`'s two exits + the SHARED `denyForbidden` with its explicit `outcome` arg + #MS3's `denyMachineUnauthenticated`), wired INSIDE the module by deep-path `require('../../audit/src/main')` because the deny helpers are unexported (the job→uuid precedent; one-way dep, no cycle). Opt out with `audit.events.authz: false`. The emit is fully contained (try/catch + enabled-gated), so **an audit failure can never change an authorization outcome** (locked by a subtract asserting identical thrown statuses with audit on vs off). Makes PCI 10.2.4 real with zero app code; the unauth-401 flood caveat is a throttling concern, not an audit one. **Two shapes worth copying:** (a) the boot lint `throw`s INSIDE `init()`'s try and INHERITS the #B57 emerg+`fs.writeSync(2,…)`+`exit(1)` from its enclosing catch — hand-rolling it would double-log and drop the `ServerEngine <stack>` prefix; (b) the boot destination line is `console.info`, NOT `.debug` — the shipped default `log_level: "info"` FILTERS debug, so a `.debug` would silently defeat the "path logged at boot" contract. `start()` is adopt-once (the `lib/job` precedent). **`self.audit()` on a released response emits a DEGRADED record** (req-derived fields null) instead of dropping — every req read in the record builder is null-safe by construction, so NO #B35 early-return: present-but-degraded beats absent for a compliance trail (the opposite of the render-path guards, deliberately). Server-side only — no dist rebuild. Live-verified daemonless: disabled = true no-op with an action calling `self.audit()`; enabled = full-schema record (real uuid requestId, socket ip, `<rule>@<bundle>`) + exactly one `authz.denied` on an unauth 401; `enabled:"true"` and an implementation-less `store` each refuse the boot with the exact emerg and never listen. **Slice 3 (2026-07-26, unreleased) adds the TAMPER-EVIDENCE hash chain + `gina audit:verify`.** Opt in with `audit.chain.enabled: true` + a signing key (`audit.chain.secret` — a literal or `${secret:VAR}`, or the `GINA_AUDIT_SECRET` env var read through the framework env reader, the #MS3 shape); every record then gains a `hash` = **`HMAC-SHA256(secret, prevHash + ':' + canonicalV1(record))`, hex, 64-zero genesis**. `canonicalV1` (key-sorted, JSON-value-space) is the canonical form insertion-order `JSON.stringify` never was — the chain store PROJECTS each record through a `JSON.parse(JSON.stringify())` round-trip BEFORE digesting, or a live `Date` in `meta` would digest as `{}` while the disk holds its ISO string. **`createChainStore` wraps the unchanged append seam** (the store stays dumb): one record in flight, the hash computed at DEQUEUE so hash order == append order == emit order, and **`prevHash` advances ONLY on a successful inner append** — a dropped record (disk full, serialize failure) never forks the on-disk chain; the next record chains from the last that landed. Consumer callbacks are guarded so a throwing callback cannot stall the queue. **Boot resume reads the trail TAIL in O(1)** (`readChainTail`, a bounded backwards window — never a whole-file scan); a torn tail from a crash mid-write is terminated with a `'\n'` (append-only preserved — the damaged bytes stay as their own unparseable line), a loud `console.warn` names the damage, and a chained `audit.chain.break` record is written as the first append carrying the damage report in `meta` — NOT a crash loop. **`gina audit:verify <bundle> @<project>`** (new offline CLI group — `verify.js` + `arguments.json` + `help.txt`, `'audit:'` in `bin/cli` allowedOffline) walks the file and reports the first break with line + reason, or the intact totals, `--format=json` for machines; exit **0** intact / **1** broken / **2** usage-or-config (`fs.writeSync` flush — a verdict must never truncate on a pipe). The verifier's rules each close a hole: hashless records are legal ONLY as a contiguous leading prefix (a pre-chain trail — anywhere later they FAIL, so inserting unhashed lines is never free); an unparseable line FAILS unless the very next record is a chained `audit.chain.break` acknowledgment (reported as a warning, never silently passed — and only ONE damaged line per ack, consecutive garbage FAILS); empty lines are skipped; a trailing unacknowledged torn tail is a WARNING (the crash-just-happened state, the next boot acknowledges); digests compared with `crypto.timingSafeEqual`. **Boot lint grows four unconditional chain-shape conjuncts** (`chain` object / `enabled` strict-boolean / `secret` non-empty-string / no unresolved `${...}`) **+ three gated guards**: no signing key → refuse (tamper-evidence without a key is silently OFF — the truthy-string class); `chain` + connector `store` → refuse (the seam carries NO ordering obligation, a chain over reordered records forks silently); and a **cross-bundle shared explicit `audit.file`** → refuse when EITHER party chains (two writers fork a linear chain; boot-detectable in both process modes by replaying the sibling's file derivation over `options.conf`). A `<32`-char key WARNS. **Threat model, stated in the docs not implied:** detects post-hoc edit, deletion, insertion and reordering by anyone WITHOUT the key (a duplicated/replayed record breaks too — its predecessor's hash no longer matches); it is explicitly NOT a defence against the WRITING PROCESS, which holds the key and can forge a whole chain — stream the JSONL trail to WORM/Object-Lock storage for that stronger adversary (the recognized PCI-DSS §10.3.3 control, already documented). **Two boundaries the chain cannot cross, pinned honestly:** truncation at the exact tail is invisible (nothing after it commits to it — read the record count), and an empty/absent trail verifies trivially. Merged-process mode stays BOOTABLE (runtime-measured single-writer) but a sibling bundle whose `audit` settings are silently ignored there is now NAMED at boot. `schema/settings.json` gains the previously-undeclared `audit.chain` block (`enabled` boolean + `secret` string, `additionalProperties:false`), so an editor now flags a mistyped `chian`/`enabeld` — which the runtime lint would otherwise catch only as a boot refusal. As change-detection it maps to **PCI-DSS v4.0.1 §10.3.4** (file-integrity/change-detection), NOT §10.3.2 (prevent-modification, which the WORM control covers). Server-side only — NO dist rebuild; enabling the chain needs a bundle restart. Live-verified daemonless (isolated home, both process modes): boots with `(tamper-evident chain: ON)`, records chain, `audit:verify` PASSES; a one-byte edit / a middle deletion / a reorder each FAIL at the right line while the untouched trail PASSES (both directions); a restart resumes the chain across the process boundary (proven by an independent HMAC re-implementation reproducing the post-restart hash from the pre-restart tail); a torn tail warns then the next boot acknowledges it with a chained break; no-key boot REFUSES; a merged pair shows the sibling-ignored warn; a cross-bundle shared `audit.file` REFUSES.
|
|
947
|
+
252. **Audit trail — `settings.json > audit` + `self.audit()` + authz auto-events (#COMPLY2 slices 1-2, 2026-07-17, 0.5.19).** A user-attributed append-only record of "who did what to which record when", DISTINCT from `lib/logger` (its own store; never the log sinks). Opt in with `audit.enabled: true` (STRICTLY boolean — a truthy `"true"` refuses to boot, the quietly-OFF class: a compliance control that silently does nothing is worse than one that is absent) and call `self.audit(action, data[, cb])`. **Record schema v1:** `{ id, ts, requestId, actor:{key,roles}, action, resource?, meta?, ip, rule, method, bundle, env }`. **The correlation key is the SHARED request id, not a second one:** slice 1 hoisted the existing `request._ginaReqId` stamp OUT of the `_reqCtxLogging` (JSON-log-mode) gate in `server.js` `onInstance` — always-on, both engines, **plus a first-seer `if (!request._ginaReqId)` guard** the gated stamp never had (it sits in the isaac double-dispatch spot ⇒ a re-entered dispatch REGENERATED the uuid; the hoist closes that latent hazard by construction). The `_reqALS` `.run()` stays JSON-gated, so the deferred always-on-ALS throughput question is untouched. One id, two consumers: audit records and JSON log lines correlate for free, and `_resolveRequestId`'s inbound `X-Request-Id` sanitization is reused — **so the id is client-influenceable BY DESIGN: it is correlation, never attribution** (attribution is the actor fields). **`rule` carries the re-keyed `<rule>@<bundle>` form** (`homepage@web`, `secure@web` — measured live), because `config.js` re-keys every rule at normalisation; a consumer grepping the trail must expect the suffix, not the bare routing.json key (same read as the #OBS1 metrics route label). `ip` is the socket remoteAddress, `::ffff:`-normalized — **`X-Forwarded-For` is NEVER read** (attacker-writable, the #OBS1 rule). `actor` is a SNAPSHOT of `session.user[audit.actorKey]` (default `"id"`) + a COPY of `user.roles`, never the whole user object (PII). **Default backend = append-only JSONL file**, path DERIVED as `<projectPath>/logs/audit-<bundle>-<env>.jsonl` — NOT read from `logsPath`, which is a **measured DEAD placeholder**: `env.json` declares it at the file ROOT, outside the `${bundle}`/`${env}` subtree config.js copies, nothing ever assigns it, so `${logsPath}` resolves to the literal string `"undefined"` and has zero template consumers (`projectPath` is inside the propagated subtree and provably resolves — it is what the live `bundlesPath` is built from). **Per-ENV filename is load-bearing:** two envs of one bundle running concurrently must never interleave writers into one file (harmless for JSONL, FATAL for the slice-3 hash chain). A relative `audit.file` resolves against the project root, never the process cwd (which depends on how the bundle was launched). **Writes are SERIALIZED** (FIFO, one in-flight `fs.write`): parallel writes on one fd complete in threadpool ORDER, and the chain needs on-disk order == emit order, so the queue ships in the MVP rather than with slice 3. `audit.store` (a connectors.json entry, resolved through the `lib/audit-store` dispatcher — the 3rd instance of the job-store mould) is wired from day 1 but **NO connector ships an implementation yet ⇒ configuring it is a clean boot REFUSAL**, never a silent fallback; `store`+`file` together is refused too (mutual exclusion, no silent precedence). **Framework auto-events are ON when audit is on:** the authz-gate deny sites emit `authz.denied` carrying `meta.outcome` (`401`|`login-bounce`|`403-roles`|`403-policy`|`401-machine` — the last added by #MS3's machine deny writer, whose records also carry the machine actor) — the real shape is **3 physical writers / 6 call sites / 5 outcomes** (`denyUnauthenticated`'s two exits + the SHARED `denyForbidden` with its explicit `outcome` arg + #MS3's `denyMachineUnauthenticated`), wired INSIDE the module by deep-path `require('../../audit/src/main')` because the deny helpers are unexported (the job→uuid precedent; one-way dep, no cycle). Opt out with `audit.events.authz: false`. The emit is fully contained (try/catch + enabled-gated), so **an audit failure can never change an authorization outcome** (locked by a subtract asserting identical thrown statuses with audit on vs off). Makes PCI 10.2.4 real with zero app code; the unauth-401 flood caveat is a throttling concern, not an audit one. **Two shapes worth copying:** (a) the boot lint `throw`s INSIDE `init()`'s try and INHERITS the #B57 emerg+`fs.writeSync(2,…)`+`exit(1)` from its enclosing catch — hand-rolling it would double-log and drop the `ServerEngine <stack>` prefix; (b) the boot destination line is `console.info`, NOT `.debug` — the shipped default `log_level: "info"` FILTERS debug, so a `.debug` would silently defeat the "path logged at boot" contract. `start()` is adopt-once (the `lib/job` precedent). **`self.audit()` on a released response emits a DEGRADED record** (req-derived fields null) instead of dropping — every req read in the record builder is null-safe by construction, so NO #B35 early-return: present-but-degraded beats absent for a compliance trail (the opposite of the render-path guards, deliberately). Server-side only — no dist rebuild. Live-verified daemonless: disabled = true no-op with an action calling `self.audit()`; enabled = full-schema record (real uuid requestId, socket ip, `<rule>@<bundle>`) + exactly one `authz.denied` on an unauth 401; `enabled:"true"` and an implementation-less `store` each refuse the boot with the exact emerg and never listen. **Slice 3 (2026-07-26, 0.6.0) adds the TAMPER-EVIDENCE hash chain + `gina audit:verify`.** Opt in with `audit.chain.enabled: true` + a signing key (`audit.chain.secret` — a literal or `${secret:VAR}`, or the `GINA_AUDIT_SECRET` env var read through the framework env reader, the #MS3 shape); every record then gains a `hash` = **`HMAC-SHA256(secret, prevHash + ':' + canonicalV1(record))`, hex, 64-zero genesis**. `canonicalV1` (key-sorted, JSON-value-space) is the canonical form insertion-order `JSON.stringify` never was — the chain store PROJECTS each record through a `JSON.parse(JSON.stringify())` round-trip BEFORE digesting, or a live `Date` in `meta` would digest as `{}` while the disk holds its ISO string. **`createChainStore` wraps the unchanged append seam** (the store stays dumb): one record in flight, the hash computed at DEQUEUE so hash order == append order == emit order, and **`prevHash` advances ONLY on a successful inner append** — a dropped record (disk full, serialize failure) never forks the on-disk chain; the next record chains from the last that landed. Consumer callbacks are guarded so a throwing callback cannot stall the queue. **Boot resume reads the trail TAIL in O(1)** (`readChainTail`, a bounded backwards window — never a whole-file scan); a torn tail from a crash mid-write is terminated with a `'\n'` (append-only preserved — the damaged bytes stay as their own unparseable line), a loud `console.warn` names the damage, and a chained `audit.chain.break` record is written as the first append carrying the damage report in `meta` — NOT a crash loop. **`gina audit:verify <bundle> @<project>`** (new offline CLI group — `verify.js` + `arguments.json` + `help.txt`, `'audit:'` in `bin/cli` allowedOffline) walks the file and reports the first break with line + reason, or the intact totals, `--format=json` for machines; exit **0** intact / **1** broken / **2** usage-or-config (`fs.writeSync` flush — a verdict must never truncate on a pipe). The verifier's rules each close a hole: hashless records are legal ONLY as a contiguous leading prefix (a pre-chain trail — anywhere later they FAIL, so inserting unhashed lines is never free); an unparseable line FAILS unless the very next record is a chained `audit.chain.break` acknowledgment (reported as a warning, never silently passed — and only ONE damaged line per ack, consecutive garbage FAILS); empty lines are skipped; a trailing unacknowledged torn tail is a WARNING (the crash-just-happened state, the next boot acknowledges); digests compared with `crypto.timingSafeEqual`. **Boot lint grows four unconditional chain-shape conjuncts** (`chain` object / `enabled` strict-boolean / `secret` non-empty-string / no unresolved `${...}`) **+ three gated guards**: no signing key → refuse (tamper-evidence without a key is silently OFF — the truthy-string class); `chain` + connector `store` → refuse (the seam carries NO ordering obligation, a chain over reordered records forks silently); and a **cross-bundle shared explicit `audit.file`** → refuse when EITHER party chains (two writers fork a linear chain; boot-detectable in both process modes by replaying the sibling's file derivation over `options.conf`). A `<32`-char key WARNS. **Threat model, stated in the docs not implied:** detects post-hoc edit, deletion, insertion and reordering by anyone WITHOUT the key (a duplicated/replayed record breaks too — its predecessor's hash no longer matches); it is explicitly NOT a defence against the WRITING PROCESS, which holds the key and can forge a whole chain — stream the JSONL trail to WORM/Object-Lock storage for that stronger adversary (the recognized PCI-DSS §10.3.3 control, already documented). **Two boundaries the chain cannot cross, pinned honestly:** truncation at the exact tail is invisible (nothing after it commits to it — read the record count), and an empty/absent trail verifies trivially. Merged-process mode stays BOOTABLE (runtime-measured single-writer) but a sibling bundle whose `audit` settings are silently ignored there is now NAMED at boot. `schema/settings.json` gains the previously-undeclared `audit.chain` block (`enabled` boolean + `secret` string, `additionalProperties:false`), so an editor now flags a mistyped `chian`/`enabeld` — which the runtime lint would otherwise catch only as a boot refusal. As change-detection it maps to **PCI-DSS v4.0.1 §10.3.4** (file-integrity/change-detection), NOT §10.3.2 (prevent-modification, which the WORM control covers). Server-side only — NO dist rebuild; enabling the chain needs a bundle restart. Live-verified daemonless (isolated home, both process modes): boots with `(tamper-evident chain: ON)`, records chain, `audit:verify` PASSES; a one-byte edit / a middle deletion / a reorder each FAIL at the right line while the untouched trail PASSES (both directions); a restart resumes the chain across the process boundary (proven by an independent HMAC re-implementation reproducing the post-restart hash from the pre-restart tail); a torn tail warns then the next boot acknowledges it with a chained break; no-key boot REFUSES; a merged pair shows the sibling-ignored warn; a cross-bundle shared `audit.file` REFUSES.
|
|
948
948
|
|
|
949
949
|
253. **The run family — `gina image:run <image>` + `gina container:ps` / `container:stop` (podman on the container host).** The verbs drive `podman` (buildah builds images but cannot run them; podman's OCI runtime is crun) on the SAME host every `image:*` verb resolves — `GINA_CONTAINER_HOST=ssh://[user@]host[:port]` env override → native → `container.host` settings — so `container:ps` always lists the host `image:build` builds onto. A build-only host (buildah present, podman absent — ssh remote rc 127 / `command not found` / native spawn ENOENT) is reported honestly: `run unavailable on this host: podman not found on <host> (buildah <ver> present — build-only host); image:run needs podman + conmon` (the buildah probe runs in the failure path only). `image:run`: detached by default, the container id ALONE on stdout with progress on stderr so `ID=$(gina image:run <image>)` captures just the id; `--format=json` → `{ host, id, name, image, ports }`; default ports = the image's own baked EXPOSE mapped same:same (read by parsing the FULL `podman image inspect` document in JS — a `--format '{{json .Config.ExposedPorts}}'` token carries a SPACE and an ssh remote shell would split it: any argv token containing a space cannot cross the ssh transport, prefer a no-token command whose output you parse in JS); `--publish=<host:ctr>[,…]` overrides, `--publish=none` disables; `--rm`; `--stream` = foreground NDJSON (`start` → `log{stream,line}` → `done{exitCode}`|`error`, fs.writeSync-flushed, the container's own exit code propagated through the remote shell + ssh). Env: `--env-var=K=V` (repeatable) + `--env-file=<local path>` compose one env file that reaches podman WITHOUT touching argv — natively a 0600 temp file; over ssh the lines ride ssh stdin into a remote `mktemp` file a shell `trap` removes (`--env-file /dev/stdin` does NOT work over ssh — sshd's stdin pipe cannot be re-opened by path, rc 125; and never `exec podman` there — exec replaces the shell so the trap never fires and the file leaks); podman lets a later duplicate key win, so the inline `--env-var` lines are composed AFTER the file's and override it; keys gated `^[A-Za-z_][A-Za-z0-9_]*$`, values newline-rejected — secrets never in argv, `${secret:KEY}` never baked. `container:ps [--all]` normalizes podman's snake_case `Ports` to `[{hostPort,containerPort,protocol}]` (null→`[]`, `hostIp` only when single-interface-bound, `range` only when the entry publishes a real range >1); `container:stop <name|id> [--time=<s>] [--force]` classifies the rung from a post-stop inspect because podman exits 0 either way — `graceful` (the container handled the signal; a gina bundle traps SIGTERM → 143), `killed` (137 = 128+SIGKILL after the grace period), `forced` (`--force` → `podman kill`); an already-exited target is a success no-op. Every user token is charset-gated BEFORE any command assembly (validate→assemble→exec, test-pinned). All stop rungs, stream, env and publish arms live-verified end-to-end on podman 5.7.0 over ssh (0.5.19).
|
|
950
950
|
|
|
951
|
-
254. **`redirect()` carries request data through the session by default; the clear-text URL form is the session-less fallback (2026-07-17,
|
|
951
|
+
254. **`redirect()` carries request data through the session by default; the clear-text URL form is the session-less fallback (2026-07-17, 0.5.19).** When a redirect's request holds params, they cross it via the session flash channel (`inheritedData` on `req.session.user || req.session`) whenever a live session exists — `router.js`'s route dispatch merges them into `req.get` on the next routed GET, then **one-shot deletes** them (a refresh does NOT replay; the URL form did — the one behaviour change). Three write sites, each gated on a live `userSession` alone: `redirect()`'s XHR branch (the `local.haltedRequestUrlResumed` conjunct that had limited it to resume-triggered XHR redirects is GONE), `redirect()`'s method-switch path (`inheritedDataIsNeeded`), and `resumeRequest()`'s GET branch. **The whole URL build AND the 2000-char cap now sit inside `if ( !userSession )`** — so a session-ful redirect never composes the string and can no longer `424` (a >2000 payload rides the session; live-measured 2600 chars intact), while a session-less bundle is byte-identical to before (`?inheritedData=<RFC5987 JSON>` in clear, capped, `424` over). `resumeRequest`'s stash sits pre-split (after the stale-`inheritedData` delete, before the popin/plain-XHR/non-XHR three-way), so **all three GET replay flavors now carry the paused data** — previously only the popin flavor did, because it alone routed through `redirect()`; plain-XHR and non-XHR silently DROPPED it. A custom `requestStorage` with no live session degrades exactly as before. Server-side only — no dist rebuild (`controller.js` is not browser-bundled). The practical security consequence (consumer-verified live): a session-ful bundle halting a credential-bearing POST no longer discloses the payload — credentials included — in clear in the URL (address bar, browser history, access logs). ⚠️ **Adjacent pre-existing crash on the same path — FIXED same day (0.5.19):** `getRoute(rule, params)` dereferenced `route.requirements[p]` in its extra-params loop, and a route declaring no `requirements` composes with `requirements: undefined` → `TypeError: Cannot read properties of undefined (reading '<key>')` for any param not in `route.param` — reached from `resumeRequest`'s `getRoute(rule, haltedRequest.params || dataAsParams)` (since #B215 that recompose is the SESSION-LESS fallback only — a session-ful GET replay redirects to the byte-exact `haltedRequest.url` — query string included since #B219 completed the capture side: the isaac engine strips the query from `req.url` before controllers run (it is parsed into `req.get`), so `pauseRequest()` snapshots `req.originalUrl || req.url` (isaac stamps `originalUrl` byte-exact before its strip; express sets it natively and never strips), `haltedRequestUrlResumed` records that full URL (middleware comparing it to `req.url` by equality should compare against `req.originalUrl || req.url` instead), and pre-#B219 path-only snapshots replay exactly as captured — because the recompose only carries query keys captured into `req.params`, i.e. keys declared in BOTH `requirements` AND `param`: a `param`-bound-only key substitutes its `:key` placeholders at match-commit yet never reaches `req.params`, and an undeclared key never does either, so the replayed GET arrived query-less, matched anyway, and rendered literal `:key` template paths as a 500 — the `inheritedData` flash cannot heal that since router.js merges it into `req.get` AFTER matching; with no session the recompose remains, its composed query params being the halted data's only travel channel), so a halted GET route with data and no `requirements` block 500'd before the stash ran (also reachable from the `url` template filter / `getUrl()` family, browser bundle included). The deref is now guarded (`route.requirements && …`, the fitsWithRequirements discipline), so extra params append to the composed URL as `?key=value` query params; the requirements-DECLARED-key skip (the path-placeholder fold) is unchanged — declaring a key in `requirements` still makes the matcher fold it in as a path placeholder, so a placeholder-less URL 404s (a `requirements` block is a matching declaration, not a sanitizer).
|
|
952
952
|
|
|
953
|
-
255. **`redirect()` is ASYNC, and its relative-path form resolves again — an unresolvable target 404s the request instead of killing the bundle (2026-07-17,
|
|
953
|
+
255. **`redirect()` is ASYNC, and its relative-path form resolves again — an unresolvable target 404s the request instead of killing the bundle (2026-07-17, 0.5.19).** Three coordinated fixes. (1) `getRouteByUrl` (lib/routing) is async and awaits its internal `compareUrls` (async since the `validator::` requirements support): the historical un-awaited call read `.past` off a pending Promise, so NO rule could EVER match server-side — the documented-primary `self.redirect('/path')` relative form had been dead for years (the ignoreWebRoot form only worked because it bypasses resolution entirely). The fix also mirrors the engine loop's exact-url fast-path (`pathname == routing[name].url || isRoute.past`) — the two matchers are declared byte-parallel ("must remain identical to server.js") and any behaviour added to one must land in the other. (2) The resolution lands in a LOCAL first and is adopted onto `req.routing` only on success: a miss or resolver rejection keeps the route that dispatched the action. This no-clobber shape is load-bearing twice over — the matcher itself reads and stamps `request.routing` on the request it is handed (a `false` there silently voids the writes), and the error reporters' diagnostics deref `req.routing.param` (with the old `false` sentinel the designed 404 turned into a crash-derived 500 from inside the reporter). A double miss now throws a clean 404 (`redirect target not found`); a resolver rejection is contained as a 500 — either way one request, never the process. (3) The response-header composer guards `!request.routing` (falsy-aware; the old `typeof == 'undefined'` guard passed the boolean `false` through to a strict-mode primitive-property write that threw from inside the error path itself — a double-fault the router's catch could not contain, ending in SIGTERM). `redirect()` being async means: prefer `return self.redirect(...)` from controller actions so a rejection threads to the framework's thenable backstop; all other forms (URL, route-name, ignoreWebRoot) complete synchronously — their promise settles same-tick. `getRouteByUrl` ships in the browser bundle with zero client callers: the async declaration rides the next per-bundle re-bake with no client behaviour change expected.
|
|
954
954
|
|
|
955
|
-
256. **Region locale data (`core/locales`) — standalone per-language files, localized `countryName`, additive `getCountries(code)` projection (#50, 2026-07-18,
|
|
955
|
+
256. **Region locale data (`core/locales`) — standalone per-language files, localized `countryName`, additive `getCountries(code)` projection (#50, 2026-07-18, 0.5.20).** The region generator (`node src/make --target=region --region=en,fr`; CSV `resources/region.csv` = datahub country-codes, `;`-separated) emits ONE STANDALONE `dist/region/<lang>.json` per requested language: the row accumulator is reset per pass (it used to APPEND each language's rows to the previous output, so every `isoShort` was duplicated with the English copy FIRST and `findOne({isoShort})` always matched English), the requireJSON-cached column mapping is cloned per pass (a mutating pass used to leak into the next language's build — a `fr,en` order lost `officialStateName` for en), and rows without an ISO 3166 alpha-2 code are dropped (2 M49 aggregates with empty `isoShort` AND empty `countryName`; `en.json` 251→249). Non-en passes overlay `countryName` from `resources/region.names.<lang>.json` beside the CSV (French ships with the framework) and FAIL FAST when that file is missing for a requested language — never silently shipping English names under a non-en filename. The names files use PLAIN CLDR territory names: the fr `-alt-short` variants are abbreviations (`É.-U.`, `R.-U.`), not display names, so plain deliberately wins — a knowing style asymmetry with the CSV's own English column, which prefers `-alt-short` (`US`, `UK`). `--outdir=<path>` overrides the target dir; `require.main === module` gates the CLI so the module is require-safe/testable. Controller side: `getCountries(code)` honors its documented projection arg ADDITIVELY — a valid string field (`capital`, `continent`, `tld`) is added to each returned row, the 4 historical fields stay untouched, no-arg output is byte-identical, unknown/dotted codes warn and are ignored — and `getLocales()` on a bundle with no locale set degrades to an empty list instead of throwing. Composition: the per-request negotiated `req.culture` selects which language's region set a request sees through the setOptions locale bridge (#B100 guarded fallback to the bundle default then `en` when the resolved language has no region file), so an `Accept-Language` flip changes `countryName` per request — warm cached routes included (#B84: the cached fast-path re-negotiates). Region files are read ONCE at boot (`core/locales/index.js` loads ALL `dist/region/*.json` onto the gina context) — regenerated data needs a bundle restart; dev hot-reload does not deliver it. **#B101 (2026-07-20) completes the bridge's last dead leg:** the setOptions country lookup filtered on a `short` key the rows never carried, so `page.view.locale` was ALWAYS `{}` (+ the date stamp) with a country code present — and an arbitrary first record without one (an undefined-valued filter key serializes away and match-alls). Now `findOne({ isoShort: userCountryCode.toUpperCase() })` (the rows are uppercase; the normalization makes a lowercase Accept-Language country segment resolve too), gated on a non-empty country code so a country-less culture (bare `en`) resolves an explicit `{}` — `page.view.locale` carries the real record (countryName / currency / capital / …) for the first time. Measured zero framework consumers pre-fix (the set site was the object's only reference), so nothing shifts under existing templates — they simply gain a working surface.
|
|
956
956
|
|
|
957
|
-
257. **FormValidator #B127/#B128/#B129 (2026-07-19,
|
|
957
|
+
257. **FormValidator #B127/#B128/#B129 (2026-07-19, 0.5.20) — numbered `is` aliases enforce, injected forms resolve their declared rule, `_case_` boolean coercion reads the case value.** (1) Numbered `is` aliases (`is0`, `is1`, … — multiple `is` conditions per field) were NEVER installed: the per-rule function typecheck `continue`d on exactly the undefined state the lazy installer required, silently skipping any `is<N>` key on client AND server. They now install (anchored `/^is\d+$/`) with a DISTINCT error key per alias on BOTH sides (client via the `window._currentValidatorAlias` handshake, server via a Node-`global` alias-root — key parity, both-fail → both keys present; previously the shared server key let a later PASSING alias erase an earlier FAILING alias's error — a silent bypass). ⚠️ Enforcement-tightening: `is<N>` rules that silently skipped START ENFORCING at pickup — sweep rule JSONs for `is<N>` keys before upgrading. Also fixed alongside: the server auto path no longer crashes on a `$`-bearing rule set (`$fields` null-guard), so server-side cross-field `$` conditions work; the `is` grammar remains ONE binary comparison (`=== !== == != < > <= >=`), no `&&` compounds, failing loudly with a warn. (2) `validateFormById(id)` (the popin/injected-form bind path) resolved the rule by FORM ID only — the `data-gina-form-rule` attribute read was unreachable — so a form injected after boot bound `{}` rules and live-check was stamped `"false"` while submit resolved the same attribute fine; the branch is attribute-first now (the id-derived name stays the fallback for attribute-less forms whose id names the rule). Live-check gate precedence: an AUTHOR-set `data-gina-form-live-check-enabled` always wins; the gate decides only when the attribute is absent (rules bound → true, none → false). (3) The cross-field `_case_` scan coerced the WRONG variable (the field being validated instead of the case value): a boolean `case: false` never matched a case field's raw "false" DOM string in single-element/live-check passes, and an outer field valued "true"/"false" clobbered string case matching for its pass. The case value itself now coerces (unconditional, matching the direct-case site's semantics) — declare boolean cases as JSON booleans; `case` values written as the strings "true"/"false" coerce to booleans consistently across sites. Worth knowing: a MATCHING case application replaces that field's rule set in the bound form's rules store persistently for the page, and there are NO standalone `maxLength`/`minLength` engine rules — length constraints are `isString`/`isNumber`/`isInteger` parameters; unknown rule keys are silently skipped. **#B138 follow-up (2026-07-20, 0.5.22):** #B127's enforcement exposed a pre-existing alias-handshake collapse — the shared `_currentValidatorAlias` slot was armed ONCE at install (the installer is gated once-per-instance) while `is()` consumes it with a delete on every call, so any RE-application against the same instance (a `conditions`/`_case_` re-evaluation, `forEachField` recursion) collapsed every numbered rule onto the bare key `is`, where the LAST-DECLARED rule overwrote its siblings and its message rendered TWICE (its own key + the collapsed key — the error-display loop has no message dedup; the digit is irrelevant — `is0` was spared only by declaration order). The alias wrapper now re-arms the slot immediately before every delegation, so `is<N>` keys stay distinct on every application; a bare `is` key never accompanies numbered aliases. Browser-bundled → consumers re-bake.
|
|
958
958
|
|
|
959
959
|
258. **CSP useNonce × server cache (#B130, shipped 0.5.21) — the bootstrap-script nonce is render-time, and cache hits re-mint.** With `Csp({useNonce:true})` + `server.cache.enable:true`, the framework-injected onGinaLoaded bootstrap `<script>` used to carry a nonce frozen at first compile (baked into the per-view compiled-template cache AND the persisted layout-cache file, which is only re-primed when missing), while the middleware minted a fresh header nonce per request — so header != markup on every later response of a cached route (misses included), breaking enforce-mode CSP. Now: (1) the loader injection is a swig conditional evaluated per execute against `page.cspNonce` (assigned pre-execute on both the compiled-cache-hit and compile branches), so cached compiled templates are nonce-agnostic; (2) stale layout-cache files written by pre-fix builds are normalized in place on the next compile (anchored on the loader's own comment-wrapper idiom + the dev Inspector script openers; idempotent; app-authored tags untouched); (3) output-cache HITS mint fresh per response — both engines' serve paths rewrite the stored CSP header (enforce and report-only) and swap the old nonce values in the stored body (exact-string split/join, base64-safe) without ever mutating the stored entry; the isaac fs read-back buffers instead of streaming only when a re-nonce applies. Verify on a live route: header nonce == every markup nonce AND rotating across responses on miss + hit. Dev/cacheless was never affected (layout re-primed every request).
|
|
960
960
|
|
|
961
|
-
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,
|
|
961
|
+
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.
|
|
962
962
|
|
|
963
|
-
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,
|
|
963
|
+
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).
|
|
964
964
|
|
|
965
965
|
261. **FormValidator autocomplete interception + live-check stale-error clear (#B134/#B135/#B136).** The Safari-autocomplete keydown interception (fields with autocomplete="off" on a live-check form: preventDefault + programmatic value rebuild behind a transient readonly) now (a) bails on modifier chords — e.metaKey/e.ctrlKey returns BEFORE preventDefault so the native select-all/copy/paste/cut/undo defaults run; pre-fix Cmd+A typed the chord letter into the field and keyboard paste was dead (execCommand("paste") is inert in unprivileged web content; the whole chord-case block is commented out, incl. the undo case that wiped input back to defaultValue) — and (b) gates on REAL Safari only (/safari/i && !/chrom(e|ium)/i): every Chromium UA carries the Safari/537.36 token, so the workaround ran on Chrome/Edge/Brave where it was never intended; WebKit-on-iOS browsers (CriOS/FxiOS) stay matched by design. And (c) the live-check whole-form pass, when it turns VALID, now clears every previously-errored field' rendered error in BOTH copies (the input/checkbox/radio processEvent pass and updateSelect') instead of leaving a stale blocking-error paragraph beside the re-enabled submit trigger — the old clear branch was gated on the stored errors map being ALREADY empty and cleared only the touched field; the pass still never renders NEW errors on untouched fields. All three are browser-bundled (dist rebuilt). Tests: validator-autocomplete-interception.test.js + validator-livecheck-stale-error-clear.test.js — extracted-real-bytes execution (brace-walked handler + branch blocks, a real-UA matrix) with red-first dist pins (Closure De-Morgans the chord bail and folds the clear loop into a brace-less for-in, so minified pins target property-name shapes, never var names).
|
|
966
966
|
|
|
@@ -970,27 +970,27 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
970
970
|
|
|
971
971
|
264. **FormValidator `query` rule — the async result path is blanket-guarded; a throw can never hang the submit pass (#B87, 0.5.22-alpha.2).** The `query` rule's XHR result lands asynchronously: by processing time the form may be unbound or the field detached (e.g. a popin closed mid-flight), and the response body is backend-controlled — any throw while processing it (a detached target's null `.form`, a malformed JSON body under a JSON content-type, a boolean checkbox value hitting the unconditional `.toLowerCase()`) left `asyncCompleted.<id>` unfired, and the submit path's async waiter hung for the whole pass (the pre-existing no-op `catch (err) { throw err; }` in `xhr.onload` rethrew instead of releasing). Fixed defensively end-to-end: the result handler is a thin guarded wrapper (`onResult` delegating to `processQueryResult`) whose catch routes to `releaseQueryWaiter` — warn + release the waiter with the field state as-is (fail-open by design: the query verdict is unknown and the server re-validates on submit) — `xhr.onload`'s catch and the now-wrapped `xhr.onerror` route to the same release, the query value normalisation lowercases only strings, and `compileError()` tolerates a non-string error (returned verbatim — nothing to compile; its only caller is string-gated, so the guard is the function's own contract, and a non-string label downstream hits the established fail-soft that degrades to the English default with a warn). The release helper is itself try-guarded (a catch handler must never throw) and names the field in its warn. Browser-bundled (form-validator.js) — consumers pick it up at their next bundle re-bake.
|
|
972
972
|
|
|
973
|
-
265. **npm refuses `config get prefix` whenever the RESOLVED VALUE trips its redactor — a UUID-shaped path segment is enough — killing any install script that probes it unguarded (#B126, fixed 2026-07-20: both gina install scripts guard the probe).** npm's `config get` gates every read on isPrivate(key, value) = protected-key OR redact(value) !== value, so a prefix like `/tmp/<uuid>/x` (CI sandboxes, generated workspace dirs) makes `npm config get prefix` exit 1 with "The prefix option is protected, and cannot be retrieved in this way" — measured on npm 10.9.8 / 11.6.2 / 11.18.0 / 12.0.1 (every current generation refuses; plain-path controls all pass), and the option's SOURCE (flag, env, npmrc) is irrelevant — only the VALUE matters. A consumer datapoint had framed this as "env-sourced protected reads, npm 11.6.2 only, 11.17 unaffected"; both axes were artifacts of WHICH PATHS each version happened to be tested with. `script/pre_install.js` and `script/post_install.js` both ran the probe unguarded at configure() start, so `npm install -g --prefix=<such-a-path> gina` died in pre_install (reproduced end-to-end in a real -g --prefix lifecycle, exact field error; fixing only one script would move the crash to the other). Fix: try/catch around the probe in BOTH scripts, falling back to the lifecycle-exported `npm_config_prefix` (npm exports the effective prefix to script children — measured present in the lifecycle env) then the node-derived default prefix (`path.resolve(process.execPath, '..', '..')`). Reusable rule: any script probing npm config must treat `npm config get <key>` as fallible — the refusal depends on the runtime VALUE, not the key list or the npm version. Install-side only — the fix ships inside the tarball, so pickup is
|
|
973
|
+
265. **npm refuses `config get prefix` whenever the RESOLVED VALUE trips its redactor — a UUID-shaped path segment is enough — killing any install script that probes it unguarded (#B126, fixed 2026-07-20: both gina install scripts guard the probe).** npm's `config get` gates every read on isPrivate(key, value) = protected-key OR redact(value) !== value, so a prefix like `/tmp/<uuid>/x` (CI sandboxes, generated workspace dirs) makes `npm config get prefix` exit 1 with "The prefix option is protected, and cannot be retrieved in this way" — measured on npm 10.9.8 / 11.6.2 / 11.18.0 / 12.0.1 (every current generation refuses; plain-path controls all pass), and the option's SOURCE (flag, env, npmrc) is irrelevant — only the VALUE matters. A consumer datapoint had framed this as "env-sourced protected reads, npm 11.6.2 only, 11.17 unaffected"; both axes were artifacts of WHICH PATHS each version happened to be tested with. `script/pre_install.js` and `script/post_install.js` both ran the probe unguarded at configure() start, so `npm install -g --prefix=<such-a-path> gina` died in pre_install (reproduced end-to-end in a real -g --prefix lifecycle, exact field error; fixing only one script would move the crash to the other). Fix: try/catch around the probe in BOTH scripts, falling back to the lifecycle-exported `npm_config_prefix` (npm exports the effective prefix to script children — measured present in the lifecycle env) then the node-derived default prefix (`path.resolve(process.execPath, '..', '..')`). Reusable rule: any script probing npm config must treat `npm config get <key>` as fallible — the refusal depends on the runtime VALUE, not the key list or the npm version. Install-side only — the fix ships inside the tarball, so pickup is 0.5.22; older tarballs cannot be retro-fixed. Tests: test/lib/pre-install-prefix-probe.test.js (probe-inside-try + fallback-chain source pins on BOTH scripts; a deterministic refusing-shim replica; the pre-fix subtract reproducing the crash).
|
|
974
974
|
|
|
975
|
-
266. **The link plugin's HTML-callback attributes (`data-gina-link-event-on-success` / `data-gina-link-event-on-error`) were dead AND deadly — carrying either killed the link's XHR with an uncaught ReferenceError before xhr.open (#B141, fixed 2026-07-21).** Three stacked layers, all live-verified in a real browser render (real clicks; the attribute-less control link on the same page fetched normally both runs): (1) SCOPE — the only live `listenToXhrEvents` definition sat INSIDE `on()` in utils/events.js (a brace slip; the adjacent "Nothing can be added after on()" comment shows it was meant to follow on() as a top-level shim global like handleXhr), so link/main.js's call resolved nothing and threw; (2) ARITY — the call passed one arg, so a reachable definition would read `data-gina-undefined-event-on-*` and silently register nothing; (3) PLUMBING — handleXhr fired the `.hlink` events named by the link INSTANCE id and dispatched them on document, while registration binds the PER-LINK id on the anchor element — name and target could never match. Fix: the definition is a top-level global in utils/events.js (body byte-preserved incl. the function-call-shape refusal warn), link/main.js passes ($link, 'link') and drops its stale commented-out local copy, and the six .hlink triggers address $link.target + $link.id. Deliberately untouched: the plain programmatic channel (gina.link.on('success'/'error') — instance id + document on BOTH sides, always aligned) and the forms' .hform path (the form validator is self-contained and coherent). Registration is per-link idempotent (gina.events dedup by event name). Browser-bundled — consumers re-bake at pickup. Tests: link-xhr-events.test.js (18 — src+dist pins incl. the column-0 top-level scope invariant, extracted-real-definition behaviour incl. the one-arg pre-fix subtract, registration/trigger alignment replica incl. the instance-id-mismatch subtract; all discriminators red-first-validated against the pre-fix blobs). **Bind-time native-affordance guard (#B288, 2026-08-06).** `bindLinks` now SKIPS three anchor shapes instead of binding them, because each asks the BROWSER for something an XHR cannot deliver: `download` (native save streams and names the file; the XHR path buffers the whole body in memory), `target` (an XHR opens no window, so the click did nothing observable), and a resolved url matching `/^#/` (an in-page fragment move — the plugin was issuing a GET for the literal "#name"). Measured in a real browser against the shipped bundle before and after: all three were intercepted, all three now do the native thing (fragment moves, tab opens, file saves). The test is against the RESOLVED url — `data-gina-link-url` wins over `href` (bindLinks resolves it that way) — so the documented placeholder idiom `<a href="#" data-gina-link-url="/real/target">` STILL binds; keying on `href` would have broken it, which is the defect adversarial review caught before implementation. Skipped at BIND time, so the anchor gets neither the generated id nor either click listener and is indistinguishable from un-marked markup — gating only the request would leave the `preventDefault` in the per-anchor click listener in force and produce a DEAD LINK (the #B141 failure above). Deliberately NOT guarded: cross-origin (a supported link feature — `linkRequest` drops credentials and relies on the target's CORS headers) and non-left-button clicks (a middle click fires `auxclick`, not `click`, so it never reaches the plugin — measured: the browser opens the tab, no request is made). **Click-time modifier guard (#B288 slice 2, 2026-08-06).** A modified click — ctrl/cmd for a new tab, shift for a new window, alt for a download in some browsers — now falls through to the browser. This CANNOT be done at bind time: a modifier is a property of the CLICK, not of the anchor. It needs THREE sites guarded, and guarding fewer is not a partial fix but no fix at all. The per-anchor listener runs first and suppresses the default, so leaving it unguarded kills the native action no matter what the document-level proxy later decides; the proxy in turn re-suppresses via `cancelEvent`, so leaving IT unguarded kills the action even when the anchor listener bailed; and `proxyClick`'s child-node listener `cancelEvent`s too — its `stopPropagation` means a click landing on a child of the anchor never reaches the other two at all and travels an entirely separate path to the same request. `registerLink`'s handler is deliberately NOT guarded, and a guard there would be dead code: `triggerEvent` dispatches a `CustomEvent` and copies native properties only when handed a `proxiedEvent`, which no call site in the module passes, so `metaKey`/`ctrlKey`/`shiftKey`/`altKey` are already `undefined` by that hop — the three native sites make it unreachable on a modified click regardless. Two of the nav plugin's own bail conditions are deliberately NOT copied across: `defaultPrevented` (link's per-anchor listener sets it on EVERY click, so that bail would fire always and disable the plugin outright) and `event.button` (a middle click fires `auxclick`). Browser-bundled ⇒ consumers restart AND re-bake. **Dispatch is keyed on REGISTRATION, never on the id's SHAPE (#B302, 0.6.x).** `bindLinks` KEEPS an author-supplied `id` — only an empty/missing/`popin.link` id gets the generated `link.click.gina-link-<instance>-<uuid>` form — while the document click proxy gated on that shape (`/^link\.click\./`, then a second `'^link.click.gina-link-' + instance.id` regex) before triggering the anchor's custom event. So `<a id="my-link" data-gina-link>text</a>` registered its `$links` entry and its custom-event listener normally, was then never triggered, and — because the per-anchor listener had already called `preventDefault` — the click did NOTHING AT ALL: no request, no navigation, no error. That is the same dead-link shape #B141 produced, reached by a different route, and it is the exact failure the #B288 note above warns about when gating a request instead of a bind. The proxy now tests `instance.$links[event.target.id]` — precisely what `getLinkById` is, and PER-INSTANCE, so it subsumes the second regex rather than merely replacing the first — which holds for BOTH id shapes and preserves the author's id instead of overwriting it (the cheap alternative, which would have silently destroyed whatever CSS/JS/anchor-target that id existed for). **Scope worth knowing before auditing a page:** only anchors whose EVENT TARGET is the anchor itself were affected, i.e. BARE-TEXT anchors (a text node is not an event target). An anchor wrapping an element — `<a id="x" data-gina-link><span>…</span></a>` — dispatches through `proxyClick`, which takes the id as a PARAMETER and never consulted the shape, so those kept working throughout; that asymmetry is why a plainly dead link went unreported. Measured in a real browser in BOTH directions with the pre-fix bundle served by route interception (`routeHits` proving it executed): pre-fix the preset-id anchor made 0 requests while the auto-id control and the child click made 1 each — the control is what proves the zero was the defect and not a broken scene — and post-fix all three fire. Covered by `test/e2e/link-preset-id.spec.js` (5 scenarios incl. an over-match subtract: an id-bearing NON-gina anchor must stay unclaimed, the failure mode the `$links` lookup could otherwise introduce). Browser-bundled ⇒ consumers restart AND re-bake. **Disabled links are refused at both dispatch sites (#B310, unreleased).** The plugin had NO disabled gate at all — the only click-time bail was the modifier guard (measured with a firing control: `aria-disabled` 0× plugin-wide vs 12× popin / 24× validator, so the zero was a real absence) — so an `aria-disabled="true"` (or native-attribute-marked) anchor still fired its XHR. Now the popin trigger gate's predicate (#B296, itself mirroring the validator's #B293 — the aria marker always counts; the native attribute counts only where `disabled` is not an IDL property, which on an anchor it never is, so both arms are live) is consulted through an `isLinkDisabled` helper at the TWO dispatch sites that turn a click into a request — the document proxy's dispatch branch and `proxyClick` (the child-node path) — AFTER their `cancelEvent`, so a registered link keeps suppressing its default, gated or not: a disabled link goes NOWHERE (no XHR, and no navigation fallback either). Deliberately ungated: `linkRequest` (the public API + the redirect funnel — a programmatic call is not operating the control, the same scope reasoning as #B308's untrusted leg) and the per-anchor CSP suppression listener (a gated link still suppresses its default — the csp-inline-handler pins stay green). Covered by `test/e2e/link-disabled-gate.spec.js` on the new `/link-gate` fixture (5 arms, natural red-first across the rebuild: the aria-anchor, child-span and native-attribute arms each fired 1 request on the pre-fix dist and 0 after, with the un-gated control at exactly 1 and `gina.link.request()` on the gated link's own registered url still firing — the API-ungated guard). Browser-bundled ⇒ consumers restart AND re-bake.
|
|
975
|
+
266. **The link plugin's HTML-callback attributes (`data-gina-link-event-on-success` / `data-gina-link-event-on-error`) were dead AND deadly — carrying either killed the link's XHR with an uncaught ReferenceError before xhr.open (#B141, fixed 2026-07-21).** Three stacked layers, all live-verified in a real browser render (real clicks; the attribute-less control link on the same page fetched normally both runs): (1) SCOPE — the only live `listenToXhrEvents` definition sat INSIDE `on()` in utils/events.js (a brace slip; the adjacent "Nothing can be added after on()" comment shows it was meant to follow on() as a top-level shim global like handleXhr), so link/main.js's call resolved nothing and threw; (2) ARITY — the call passed one arg, so a reachable definition would read `data-gina-undefined-event-on-*` and silently register nothing; (3) PLUMBING — handleXhr fired the `.hlink` events named by the link INSTANCE id and dispatched them on document, while registration binds the PER-LINK id on the anchor element — name and target could never match. Fix: the definition is a top-level global in utils/events.js (body byte-preserved incl. the function-call-shape refusal warn), link/main.js passes ($link, 'link') and drops its stale commented-out local copy, and the six .hlink triggers address $link.target + $link.id. Deliberately untouched: the plain programmatic channel (gina.link.on('success'/'error') — instance id + document on BOTH sides, always aligned) and the forms' .hform path (the form validator is self-contained and coherent). Registration is per-link idempotent (gina.events dedup by event name). Browser-bundled — consumers re-bake at pickup. Tests: link-xhr-events.test.js (18 — src+dist pins incl. the column-0 top-level scope invariant, extracted-real-definition behaviour incl. the one-arg pre-fix subtract, registration/trigger alignment replica incl. the instance-id-mismatch subtract; all discriminators red-first-validated against the pre-fix blobs). **Bind-time native-affordance guard (#B288, 2026-08-06).** `bindLinks` now SKIPS three anchor shapes instead of binding them, because each asks the BROWSER for something an XHR cannot deliver: `download` (native save streams and names the file; the XHR path buffers the whole body in memory), `target` (an XHR opens no window, so the click did nothing observable), and a resolved url matching `/^#/` (an in-page fragment move — the plugin was issuing a GET for the literal "#name"). Measured in a real browser against the shipped bundle before and after: all three were intercepted, all three now do the native thing (fragment moves, tab opens, file saves). The test is against the RESOLVED url — `data-gina-link-url` wins over `href` (bindLinks resolves it that way) — so the documented placeholder idiom `<a href="#" data-gina-link-url="/real/target">` STILL binds; keying on `href` would have broken it, which is the defect adversarial review caught before implementation. Skipped at BIND time, so the anchor gets neither the generated id nor either click listener and is indistinguishable from un-marked markup — gating only the request would leave the `preventDefault` in the per-anchor click listener in force and produce a DEAD LINK (the #B141 failure above). Deliberately NOT guarded: cross-origin (a supported link feature — `linkRequest` drops credentials and relies on the target's CORS headers) and non-left-button clicks (a middle click fires `auxclick`, not `click`, so it never reaches the plugin — measured: the browser opens the tab, no request is made). **Click-time modifier guard (#B288 slice 2, 2026-08-06).** A modified click — ctrl/cmd for a new tab, shift for a new window, alt for a download in some browsers — now falls through to the browser. This CANNOT be done at bind time: a modifier is a property of the CLICK, not of the anchor. It needs THREE sites guarded, and guarding fewer is not a partial fix but no fix at all. The per-anchor listener runs first and suppresses the default, so leaving it unguarded kills the native action no matter what the document-level proxy later decides; the proxy in turn re-suppresses via `cancelEvent`, so leaving IT unguarded kills the action even when the anchor listener bailed; and `proxyClick`'s child-node listener `cancelEvent`s too — its `stopPropagation` means a click landing on a child of the anchor never reaches the other two at all and travels an entirely separate path to the same request. `registerLink`'s handler is deliberately NOT guarded, and a guard there would be dead code: `triggerEvent` dispatches a `CustomEvent` and copies native properties only when handed a `proxiedEvent`, which no call site in the module passes, so `metaKey`/`ctrlKey`/`shiftKey`/`altKey` are already `undefined` by that hop — the three native sites make it unreachable on a modified click regardless. Two of the nav plugin's own bail conditions are deliberately NOT copied across: `defaultPrevented` (link's per-anchor listener sets it on EVERY click, so that bail would fire always and disable the plugin outright) and `event.button` (a middle click fires `auxclick`). Browser-bundled ⇒ consumers restart AND re-bake. **Dispatch is keyed on REGISTRATION, never on the id's SHAPE (#B302, 0.6.x).** `bindLinks` KEEPS an author-supplied `id` — only an empty/missing/`popin.link` id gets the generated `link.click.gina-link-<instance>-<uuid>` form — while the document click proxy gated on that shape (`/^link\.click\./`, then a second `'^link.click.gina-link-' + instance.id` regex) before triggering the anchor's custom event. So `<a id="my-link" data-gina-link>text</a>` registered its `$links` entry and its custom-event listener normally, was then never triggered, and — because the per-anchor listener had already called `preventDefault` — the click did NOTHING AT ALL: no request, no navigation, no error. That is the same dead-link shape #B141 produced, reached by a different route, and it is the exact failure the #B288 note above warns about when gating a request instead of a bind. The proxy now tests `instance.$links[event.target.id]` — precisely what `getLinkById` is, and PER-INSTANCE, so it subsumes the second regex rather than merely replacing the first — which holds for BOTH id shapes and preserves the author's id instead of overwriting it (the cheap alternative, which would have silently destroyed whatever CSS/JS/anchor-target that id existed for). **Scope worth knowing before auditing a page:** only anchors whose EVENT TARGET is the anchor itself were affected, i.e. BARE-TEXT anchors (a text node is not an event target). An anchor wrapping an element — `<a id="x" data-gina-link><span>…</span></a>` — dispatches through `proxyClick`, which takes the id as a PARAMETER and never consulted the shape, so those kept working throughout; that asymmetry is why a plainly dead link went unreported. Measured in a real browser in BOTH directions with the pre-fix bundle served by route interception (`routeHits` proving it executed): pre-fix the preset-id anchor made 0 requests while the auto-id control and the child click made 1 each — the control is what proves the zero was the defect and not a broken scene — and post-fix all three fire. Covered by `test/e2e/link-preset-id.spec.js` (5 scenarios incl. an over-match subtract: an id-bearing NON-gina anchor must stay unclaimed, the failure mode the `$links` lookup could otherwise introduce). Browser-bundled ⇒ consumers restart AND re-bake. **Disabled links are refused at both dispatch sites (#B310, 0.6.5).** The plugin had NO disabled gate at all — the only click-time bail was the modifier guard (measured with a firing control: `aria-disabled` 0× plugin-wide vs 12× popin / 24× validator, so the zero was a real absence) — so an `aria-disabled="true"` (or native-attribute-marked) anchor still fired its XHR. Now the popin trigger gate's predicate (#B296, itself mirroring the validator's #B293 — the aria marker always counts; the native attribute counts only where `disabled` is not an IDL property, which on an anchor it never is, so both arms are live) is consulted through an `isLinkDisabled` helper at the TWO dispatch sites that turn a click into a request — the document proxy's dispatch branch and `proxyClick` (the child-node path) — AFTER their `cancelEvent`, so a registered link keeps suppressing its default, gated or not: a disabled link goes NOWHERE (no XHR, and no navigation fallback either). Deliberately ungated: `linkRequest` (the public API + the redirect funnel — a programmatic call is not operating the control, the same scope reasoning as #B308's untrusted leg) and the per-anchor CSP suppression listener (a gated link still suppresses its default — the csp-inline-handler pins stay green). Covered by `test/e2e/link-disabled-gate.spec.js` on the new `/link-gate` fixture (5 arms, natural red-first across the rebuild: the aria-anchor, child-span and native-attribute arms each fired 1 request on the pre-fix dist and 0 after, with the un-gated control at exactly 1 and `gina.link.request()` on the gated link's own registered url still firing — the API-ungated guard). Browser-bundled ⇒ consumers restart AND re-bake. **The request belongs to the CLICKED anchor, never to the first url match (#B287, 0.6.x).** `linkRequest` re-derived its element from the url — `getLinkByUrl` walks the registrations and returns the FIRST whose `.url` matches — so two anchors sharing one url collapsed onto whichever registered first (document order): its `data-gina-loading` armed, its HTML-callback attributes were read, its `.hlink` events fired, whichever anchor was actually operated. The dispatch listener in `registerLink` had ALREADY resolved the correct registration (`getLinkById(e.target.id)`) and then discarded it, passing only a bare url string back. Fixed with a module-scoped hand-off (`_operatedLink`), set at the dispatch site and consumed-and-cleared as `linkRequest`'s FIRST step — before the transport swap — so an early exit can never leak it into a later call; both click paths (the direct anchor listener AND `proxyClick`'s child delegation) dispatch into that same listener, so the single hand-off site covers both. The public `gina.link.request(url)` keeps the historical first-match lookup — a bare url is all it has. Measured live in a real browser in BOTH directions (e2e `link-same-url-attribution.spec.js`, 4 arms): pre-fix a direct click AND a child click on the SECOND same-url anchor each armed the FIRST; post-fix each arms its own, with the first-anchor control behaving identically in both trees. The same run settled an older open question empirically: a child click on a DISTINCT-url anchor works in BOTH trees — the previously-reported "no transition at all" for that shape did not reproduce. **And the publish is now guarded (#B326).** `gina.link = merge(gina.link, instance)` was the merge-publish shape the popin and nav plugins were already cured of: a second construction deep-merged itself into the live published instance, freezing accessors and scalar state. link now publishes ONCE — `gina.link` IS the first activated instance; later constructions have nothing to publish. Measured before the change: `gina.link` is never pre-seeded, and a merge into an undefined target returns the instance itself — so a page constructing a single handler (the overwhelmingly common case) is behaviourally identical, and only the freeze path changes. Unit pins: `link-operated-registration.test.js` (13 — hand-off shape and consume-first ordering, the extracted consume/resolve slice EXECUTED incl. the lookup short-circuit, publish-once with a nav-lockstep control, dist fidelity). Browser-bundled ⇒ consumers restart AND re-bake.
|
|
976
976
|
|
|
977
977
|
267. **`req.files[].size` was a racy under-count — the per-file byte counter is incremented in the liner Transform one pipe hop DOWNSTREAM, but the record was built final at the SOURCE stream's 'end', when the objectMode liner (16-OBJECT high-water marks) can still hold un-counted queued chunks (#B142, fixed 2026-07-21; surfaced by a consumer verifying the #B103 pickup — measured live 16/16 uploads short by a whole-chunk amount, ~25% on a 1.5MB file, while md5 stayed perfect: bytes on disk were always intact, only the number lied).** The fix captures the pushed record in a per-part closure var and finalizes `size` in a live `liner._flush`, with probe-measured ordering guarantees: `_flush` runs after the LAST `_transform` and strictly before the write stream can emit 'finish', and the request only resumes after every write stream finished — so the patched value is final before any consumer (incl. `self.store()`, which copies `files[i].size`) reads `request.files`. On a settled pipeline `_flush` can run BEFORE the source's 'end' listener; the push then reads the already-complete count — both interleavings yield the exact byte size (both measured). The push deliberately stays at source-'end': busboy emits parts sequentially, so that preserves `request.files` part order, while flush COMPLETION order can invert across parts. Server-side only — no dist rebuild; running bundles pick it up at restart. Sibling discovered during the same verification and FIXED as #B143 (2026-07-21): the per-writeStream 'finish'/'error' listeners attached only inside `busboy.on('finish')` — after the WHOLE body was parsed — and Node never replays 'finish' for a late listener, so an early small file whose write stream finished while a later large part was still streaming lost the listener race and the request never resumed (measured live: a throttled two-file upload hung deterministically, no log line; unthrottled ~1 in 13 — fast clients exposed, rarely; unauthenticated, both engines, single-file unaffected). Both listeners now arm AT STREAM CREATION in the 'file' handler, counted by a pending counter plus a busboyDone flag set at parse end; resume fires on busboyDone && pending === 0, whichever event lands last — closing the race for every ordering (the same zero-pending branch still covers the fields-only #B93 terminal), and a mid-stream write error (missing dir, disk full) now gets a guarded 500 instead of the historical unhandled-'error' uncaughtException SIGTERM. Live post-fix: the throttled two-file was-hang 200s with exact sizes and preserved part order; fields-only, single-file, and three-file arms all green. Server-side only — no dist rebuild; restart to apply. Tests: upload-size-accuracy.test.js (11 — red-first-validated source pins, control-gated extraction of the live _transform+_flush bytes, a stalled-sink choreography replica making the race deterministic, and a frozen pre-fix subtract) + multipart-multifile-resume.test.js (15 — red-first pins incl. loop-gone negatives, a real-Writable never-replays proof with a can-fail control, the deterministic early-finisher race replica + frozen pre-fix subtract, and a REAL busboy paced two-part drive where the early sink provably finishes before parse end). Downstream companion (#B223, fixed 2026-08-03): `Controller.store()`'s mover now streams each file to a temp sibling and publishes with an atomic rename — a reader never observes a partial file under the final name, a pre-existing destination is replaced only on success, and a FAILED move no longer destroys the staged source nor settles the callback twice — and store() propagates the REAL filesystem Error (every failure was previously masked as `No file to upload`, and a source-side stream error had no listener: an uncaughtException -> SIGTERM bundle kill; the genuinely-empty case keeps the historical message). Server-side only — no dist rebuild; restart to apply.
|
|
978
978
|
|
|
979
|
-
268. **Consumer-probeable multipart write-error — a per-upload-group `simulateWriteError` flag deterministically fires the guarded-500 write-error path, honoured OUTSIDE production scope only (#B144,
|
|
979
|
+
268. **Consumer-probeable multipart write-error — a per-upload-group `simulateWriteError` flag deterministically fires the guarded-500 write-error path, honoured OUTSIDE production scope only (#B144, 0.5.24).** A consumer could not SAFELY re-confirm the #B143 mid-stream write-error crash-guard on their own upload surface after a pickup: every real trigger was either a global config change (an unwritable per-group `path` breaks REAL uploads to that group) or a whole-bundle destabiliser (a full disk). Fix: a group carrying `simulateWriteError: true` makes the `'file'` handler create the REAL write stream, arm the REAL #B143 terminal listeners, then synthetically `destroy()` it → the production `'error'` listener → the production `throwError(500)` with the EXACT terminal semantics of a real ENOSPC/EIO (an errored stream never emits `'finish'`, so it never decrements the pending counter and the request stays terminal at the 500). A multi-file probe destroys EVERY tagged part's stream, but throwError's `!res.headersSent` guard collapses the N errors to exactly ONE 500 (measured 2026-07-22: busboy does NOT stall on the unconsumed first part — both parts parse and both streams are destroyed; the guard is what makes it one 500). The flag is gated on `!self.isProductionScope()` (NODE_SCOPE_IS_PRODUCTION), so dev/local/beta/testing probe while production stays inert; a boot warn scans `upload.groups` for the flag either way (production: "IGNORED — remove before shipping"; else "PROBE active"). No magic group name — any consumer-named group carrying the flag; nothing ships active (the settings template carries only a COMMENTED `_probe_fail` sample), and an unconfigured group still 400s. The `group="…"` upload 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; `createWriteStream` opens the fd before the destroy, so a probe may leave a 0-byte tmp file (point the probe group's `path` at a tmp dir). Both engines; server-side only — no dist rebuild, restart to apply. Tests: upload-write-error-probe.test.js (source pins red-first-validated against the pre-fix blob; a REAL @rhinostone/busboy drive of a single-part and a small-then-large two-part probe body through a faithful 'file'-handler replica; a prod-scope + flag-off subtract validating the 500-counter reads both one and zero; a destroy→'error' mechanism proof). Ships alongside #B145 (the sibling `mkdirSync`-itself crash-guard in the same handler).
|
|
980
980
|
|
|
981
981
|
269. **Upload progress for the staged upload client layer (#R8 slice 1, `0.5.24-alpha.2`).** The staging POST of a `data-gina-form-upload-*` file input now reports real client-to-server wire progress: `send()` assigns `xhr.upload.onprogress` FRESH on every send (the module-scoped `xhr` is created once and reused across sends, so a stale handler would replay the previous send's closure — wrong id — on later sends; mirror the per-send onreadystatechange/onprogress/ontimeout assignments), gated to `gina-upload-*` virtual-form sends, dispatching a NEW registered event `uploadProgress.<uploadFormId>` (the events-array registration is REQUIRED — `on()` validates names against the plugin registry; `.hform`-suffixed names bypass validation) with `{ status: 100, progress: <int 0-100, null when lengthComputable is false>, loaded, total, lengthComputable, files: [names] }` — 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: copied onto the virtual form as `data-gina-form-event-on-upload-progress`, bound by listenToXhrEvents on `uploadProgress.hform`, no default when absent) + a declarative indicator `data-gina-form-upload-progress="<elId>"` defaulting to `<fieldId>-progress` (the -preview/-error family; stored as a STRING id like errorField, unlike previewContainer which stores the element), opt-in by element presence. The updater feature-detects the target: a native `<progress>` tracks value=loaded/max=total (indeterminate = 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` (preparing|uploading|indeterminate|processing|complete|error) styling hooks, with NO hardcoded wording (i18n-neutral — label via CSS on the state attribute). Lifecycle: preparing at selection (covers the FileReader read/assembly phase before the first wire frame), uploading per frame, processing at bytes-done (xhr.upload.onloadend — fires when the browser finishes SENDING, marking the server post-processing window between the last byte and the response; advances the state attribute ONLY, leaving value/max/percent as the last uploading frame left them so a styled determinate bar stays visually full instead of regressing to an empty track, then onUpload overwrites it), complete/error finalized in onUpload (one chokepoint covers the success path 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 — a distinct event name was chosen over overloading it with a direction discriminator). Browser-bundled → prod dist rebuild in-commit; consumers re-bake at pickup. Live-verified in a throttled real browser (48 monotonic frames, full lifecycle incl. error arm, pre-fix-bundle subtract moved nothing). Tests: `test/core/validator-upload-progress.test.js` (46 — source pins, extracted-shipped-bytes jsdom behavioral drive, replicas, red-first dist pins; the processing state adds §11-§13: onloadend-delegate + early-return source pins, a jsdom drive whose subtract control removes the early-return and reproduces the value/percent strip it prevents, and the onloadend dist-fidelity pin).
|
|
982
982
|
|
|
983
|
-
270. **Upload destination mkdir is crash-guarded — a configured upload group whose custom `path` cannot be created no longer kills the bundle (#B145, fixed 2026-07-22,
|
|
983
|
+
270. **Upload destination mkdir is crash-guarded — a configured upload group whose custom `path` cannot be created no longer kills the bundle (#B145, fixed 2026-07-22, 0.5.24).** The per-file destination resolve in the busboy `'file'` handler (`fileUploadDir = opt.groups[g].path || uploadDir`, then mkdir-if-missing before `createWriteStream`) ran `fs.mkdirSync(fileUploadDir, { recursive: true })` UNGUARDED: a group whose `path` has a read-only / EACCES / EROFS parent made mkdirSync throw SYNCHRONOUSLY inside the parser callback → uncaughtException → SIGTERM — an unauthenticated single-request bundle-kill of the #B30/#B97 family (the multipart parse precedes routing + all middleware; both engines share the branch). The #B49 mkdir-before-stream rationale covered only the missing-dir STREAM error, never mkdirSync itself failing. Fix: the existsSync/mkdirSync pair is wrapped in try/catch → `console.error` + a guarded `throwError(response, 500, …)` naming the group + path + `return false` — a server CONFIG problem answers 500, not 400 (the #B50 unconfigured-group 400 is upstream and unchanged), and the request stays terminal there. Distinct from #B144 (that probes the write-stream `'error'`→500 branch; this crash never reached that branch — found by the #B144 design measurement, which is why they ship together). Server-side only — no dist rebuild; restart to apply. Tests: upload-config.test.js §07 (source pins + a guarded-replica 500/return-false arm + a creatable-dir control + an unguarded-pre-fix subtract proving the sync throw; the #B49 pins held verbatim inside the try).
|
|
984
984
|
|
|
985
985
|
271. **Drag-and-drop for the staged upload client layer (#R8 slice 2, `0.5.24-alpha.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 and upload progress — runs with zero duplicated logic (the change handler reads only `currentTarget` off its event, 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 — also the first-wins guard: one zone serves one input, same-owner re-binds are silent no-ops) and `data-gina-upload-dropzone-state` (`idle` → `over` on a file-drag hover, depth-counted so child-boundary crossings never flicker → `dropped` at drop → back to `idle` at the same onUpload complete/error chokepoint that finalizes upload progress, and at the reset/delete strip) — 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) — the graceful client mirror of the single-file picker, with configured upload groups still enforcing `isMultipleAllowed` server-side. A bare file input (no attribute) already accepts native browser drops through the same change handler — the attribute exists to delegate a larger/styled element. Browser-bundled (validator plugin) ⇒ consumers pick this up with a bundle re-bake.
|
|
986
986
|
|
|
987
|
-
272. **Staged-upload client layer stores binary files byte-identical — the hand-assembled DOMString multipart body UTF-8-inflated every file byte ≥ 0x80 (#B148, fixed 2026-07-22,
|
|
987
|
+
272. **Staged-upload client layer stores binary files byte-identical — the hand-assembled DOMString multipart body UTF-8-inflated every file byte ≥ 0x80 (#B148, fixed 2026-07-22, 0.5.24).** `processFiles()` historically read each staged File (`FileReader.readAsArrayBuffer`), converted the buffer to a per-byte JS string (`ab2str`) and string-concatenated the whole multipart body, which `send()` transmitted as `xhr.send(<DOMString>)` — a DOMString is UTF-8-encoded on the wire, so every byte ≥ 0x80 became a 2-byte sequence: any real binary upload (image/PDF/archive) was stored inflated + corrupted server-side (measured ×1.49 on a cycling-byte fixture; a real PNG's 0x89 signature byte arrived as 0xC2 0x89 and no longer parsed; drop and native-picker paths identically). CRITICALLY, the corruption only STARTED at 0.5.22: the pre-#B103 server's two since-removed string-decode layers (`setEncoding('utf8')` + the liner's `toString()`/charCode-mod-256 re-encode) EXACTLY reversed this client inflation at every chunking — two wrongs cancelling — so ≤ 0.5.21 round-trips were byte-clean and the #B103 server fix exposed the client defect. Fix: the body is assembled as a `Blob` — [field parts (string), per-file header (string), the File object itself (raw bytes), CRLF, …, closer] — with the multipart FRAMING byte-identical (same boundary delimiters, same `name=`/`group=`/`filename=` disposition-parameter set = the documented upload-group wire vehicle, same per-part headers; `Content-Length` now carries `File.size`, numerically identical to the historical char count): zero server change, zero wire-contract change; `xhr.send(Blob)` transmits verbatim and the explicitly-set multipart `Content-Type` header survives; the `FileReader`/`ab2str` stage is retired (its only consumer was the wire — previews come from the server response, not a client read). Disposition-param values (`name`/`group`/`filename`) are now percent-escaped for CR/LF/double-quote per RFC 7578 §5.1.1 (an unescaped quote produced a malformed part). Files corrupted by this defect are LOSSLESSLY recoverable — the stored bytes are exactly the UTF-8 encoding of the original byte sequence: decode utf8 → re-encode latin1 restores the exact original (all 256 byte values round-trip). Browser-bundled (validator plugin) ⇒ consumers pick the fix up with a bundle RE-BAKE, and the 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). Tests: validator-upload-binary-wire.test.js (source pins + a control-gated extract+eval of the shipped assembly asserting exact whole-body bytes + a round-trip through the real busboy fork incl. the `group=` param and a UTF-8 filename + a frozen pre-fix subtract reproducing the inflation + red-first dist pins).
|
|
988
988
|
|
|
989
|
-
273. **Error responses carry a first-class incident ref (#ERRREF).** Both `throwError` builders (the engine-attached one in core/server.js + the controller-side `this.throwError`) mint a short incident ref per error — 6 uppercase hex from `crypto.randomBytes(3)`, or a relay-safe caller-supplied `ref` (charset `[\w.-]`, max 32, the X-Request-Id sanitize discipline; anything else re-mints, so log forging is neutralized) — returned as a top-level `ref` field on every JSON error body in ALL scopes (a random correlation key, no server detail; the stack-egress gate still strips `stack` outside local scope), stamped on the custom-error-page data (`eData.ref`) and the inline HTML fallback pages, and paired server-side in ONE error-level log line per thrown error (`[ ref XXXXXX ][ req <requestId> ] METHOD [ code ] url` + full message/stack/cause) emitted BEFORE the wire sanitize — every scope, production included, composing with the always-on request correlation id instead of duplicating it. Consolidates the former in-gate stack-egress log emits + per-branch summary lines into one line per error, and closes two gaps: the controller JSON/XHR branch stripped the stack with NO pre-strip log (a production API error's stack was lost server-side entirely), and the server XHR summary line logged no message. `Error.cause` reaches the log, never the wire. Always-on, no config; additive/back-compat on the wire. The two mint copies are kept byte-identical (test-pinned). Server-side only — no dist rebuild; running bundles pick it up at restart. Tests: test/core/error-ref.test.js (36 — control-gated extraction-executed compositions of the real bytes + a frozen pre-fix subtract) with server-throwerror-stack-gate.test.js §02 realigned to the log-free helper. **#A11Y3 (2026-08-04,
|
|
989
|
+
273. **Error responses carry a first-class incident ref (#ERRREF).** Both `throwError` builders (the engine-attached one in core/server.js + the controller-side `this.throwError`) mint a short incident ref per error — 6 uppercase hex from `crypto.randomBytes(3)`, or a relay-safe caller-supplied `ref` (charset `[\w.-]`, max 32, the X-Request-Id sanitize discipline; anything else re-mints, so log forging is neutralized) — returned as a top-level `ref` field on every JSON error body in ALL scopes (a random correlation key, no server detail; the stack-egress gate still strips `stack` outside local scope), stamped on the custom-error-page data (`eData.ref`) and the inline HTML fallback pages, and paired server-side in ONE error-level log line per thrown error (`[ ref XXXXXX ][ req <requestId> ] METHOD [ code ] url` + full message/stack/cause) emitted BEFORE the wire sanitize — every scope, production included, composing with the always-on request correlation id instead of duplicating it. Consolidates the former in-gate stack-egress log emits + per-branch summary lines into one line per error, and closes two gaps: the controller JSON/XHR branch stripped the stack with NO pre-strip log (a production API error's stack was lost server-side entirely), and the server XHR summary line logged no message. `Error.cause` reaches the log, never the wire. Always-on, no config; additive/back-compat on the wire. The two mint copies are kept byte-identical (test-pinned). Server-side only — no dist rebuild; running bundles pick it up at restart. Tests: test/core/error-ref.test.js (36 — control-gated extraction-executed compositions of the real bytes + a frozen pre-fix subtract) with server-throwerror-stack-gate.test.js §02 realigned to the log-free helper. **#A11Y3 (2026-08-04, 0.6.4) — those inline HTML fallback pages are now CONFORMING DOCUMENTS.** A per-file `a11yErrorDocument(code, bodyHtml, req)` helper (duplicated in core/server.js + controller.js so each error path stays self-contained) wraps the existing body in `<!doctype html><html lang="…"><head><title>Error N</title></head><body><main>…</main></body></html>`; the body markup — and therefore the `ref` above — is embedded VERBATIM, so nothing in this entry's contract changes. It fixes a real DEFAULT-PATH defect, not an edge case: the HTTP/1.1 branch closed `<body>` with an *opening* `<html>` tag (so the document never terminated) and both branches opened two `<pre>` while closing one, and a default scaffolded project ships no `templates/html/errors/` directory — `errorFiles` is populated only by config.js's readdir of that path — so `hasCustomErrorFile` is false and this markup IS what an unhandled 500 serves. (`core/template/error/` exists but has ZERO readers; it is not scaffolding.) A second trigger: even a project WITH error pages falls back here when the error came from `processMiddlewares`. `lang` comes from `a11yLangTag(req)` — negotiated culture normalized to BCP-47 (`en_CM`→`en-CM`, q-value stripped, `_`→`-`), falling back to `en` rather than emitting a tag AT cannot parse; the same normalization now guards `page.view.lang` and the three render-nunjucks error fallbacks, and adds doctype+`lang` to render-swig's layoutless/iframe wrapper. The locale LOOKUP deliberately keeps the raw underscore form (its resolution behaviour is a separate concern, and `getEnvVar('GINA_CULTURE') || 'en_CM'` is source-pinned) — only what reaches the document is normalized. ⚠️ Two hazards for anyone editing these sites: the layoutless wrapper's `</body>` is load-bearing beyond markup validity (release-banner's `maybeInject()` splices the dev banner on `/<\/body>/i`, and no test would catch its loss), and `controller.test.js` source-pins the literal `var msgString = '<h1 class="status">Error ` — so the wrap must stay a SEPARATE statement at the `res.end()` site, never prepended to that assignment. `helpers/context.js`'s HTML error branch was deliberately NOT touched: `var isUsingTemplate = isUsingTemplate` makes it permanently `undefined`, so that branch is unreachable dead code and the file always answers `application/json`. Server-side only — no dist rebuild; running bundles pick it up at restart. Full suite 14546/14546 green.
|
|
990
990
|
|
|
991
|
-
274. **Staged-upload client layer: the action-attribute fallback no longer clobbers the staging action, a missing preview element no longer crashes the success handler, and an upload-only input no longer logs a spurious warn+error pair on every bind (#B146/#B147 2026-07-22 + #B149 2026-07-23,
|
|
991
|
+
274. **Staged-upload client layer: the action-attribute fallback no longer clobbers the staging action, a missing preview element no longer crashes the success handler, and an upload-only input no longer logs a spurious warn+error pair on every bind (#B146/#B147 2026-07-22 + #B149 2026-07-23, 0.5.25).** Two LOW `data-gina-form-upload-*` defects surfaced by the #R8 smoke, both in `core/plugins/lib/validator/src/main.js`. **#B146:** `checkUploadUrlActions`'s `checkAction` default-route fallback hardcoded `setAttribute('data-gina-form-upload-action', route.toUrl())` regardless of which attribute it was checking — so a file input declaring only its staging action had it silently repointed at the resolved reset/delete default route (`upload-delete-from-tmp-xml`), sending the staging POST to the delete route; it compounded to a SILENT failure when `toUrl()`'s absolute origin (e.g. 127.0.0.1 vs localhost) tripped a CORS preflight → XHR status-0 → the commented-out status-0 branch in `send()`. Fixed by writing the attribute actually being checked: `setAttribute(action, …)`. The origin/CORS half is deliberately DEFERRED — `toUrl()` always prepends the hostname (no clean origin-less form: `.url` diverges from the declared-attribute path and skips webroot normalization), and the real silent-death is the disabled status-0 branch in `send()`, a separate item. **#B147:** `previewContainer` is a `getElementById` RESULT (element|null), but the guard read `typeof(previewContainer) != 'undefined'` — and `typeof null === 'object'`, so a preview-element MISS set `hasPreviewContainer` true, stored a null container, and the success handler later dereferenced `.id` (TypeError on any upload configured without a preview target). Fixed with the typeof-null guard pattern: `&& previewContainer`. **#B149** (2026-07-23, `986c5b9d`): the SAME `checkAction` checks all three action attributes, but the switch defaults only `-action`/`-reset-action` — `-delete-action` (removes an ALREADY-SAVED file, an app-specific endpoint, so deliberately no framework default) fell through to the error branch on every bind of a working upload-only input, emitting a `console.warn` ("Trying to get default route" — when there is none) + `console.error` + a visible `$errorContainer.innerHTML` write. Fixed quiet-at-bind: a no-default + absent action is a single `console.debug` + early `return`; a WITH-default action that genuinely fails to resolve STILL errors (surgical); the delete requirement stays enforced lazily at the use site (`onUploadResetOrDelete` throws when a delete fires with no URL). Browser-bundled (validator plugin) ⇒ consumers pick all three up with a bundle RE-BAKE; the server half is untouched. Tests: `test/core/validator-upload-action-guard.test.js` (source pins + an extract+eval of the shipped `checkAction` proving the reset-fallback no longer clobbers the staging action + a frozen #B146 subtract + a #B147 guard replica/subtract + the #B149 quiet-at-bind suites 06-08 (debug+return pins, recording-console behaviour, dist fidelity) + red-first dist pins).
|
|
992
992
|
|
|
993
|
-
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
|
|
993
|
+
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).
|
|
994
994
|
|
|
995
995
|
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).
|
|
996
996
|
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.
|
|
@@ -998,7 +998,7 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
998
998
|
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.
|
|
999
999
|
|
|
1000
1000
|
279. **Transient-vs-permanent connector-error classification (#CE1, `26d803bd`+`d9231eff`, 0.5.25).** `lib/connector-error` (`classify(err) → {isTransient, reason}` + in-place `stamp(err)`) stamps `err.isTransient` (boolean; explicit `false` = classified-permanent, not merely absent) + `err.transientReason` (normalized token, `null` when permanent) on every datastore query error reaching the controller, so an app renders an honest retryable response by branching on the CONDITION instead of string-matching vendor text. Wired at 2 error sites per connector (Promise reject + direct-callback) across couchbase/mongodb/mysql/postgresql/scylladb/sqlite; `stamp()` RETURNS err so the call FOLDS into the terminal (`_reject(lib.connectorError.stamp(err))`) — the two-line form pushes `_queryEntry.resultSize` past inspector.test.js's fixed-width QI `substring(idx,idx+1800/900)` windows (thin post-fix margins 12/22 — RE-MEASURE on any connector error-branch edit; same fragility class as the #B152 260-char window). Taxonomy, first-match-wins IN ORDER: socket errno (ECONNREFUSED/ECONNRESET/ETIMEDOUT/EPIPE/EHOSTUNREACH/ENETUNREACH/ENETDOWN/EAI_AGAIN transient; **ENOTFOUND EXCLUDED** = DNS misconfig, not a blip) → mysql/sqlite string codes → **node:sqlite NUMERIC `errcode` primary byte** (MEASURED: a busy error is `code:'ERR_SQLITE_ERROR'`+`errcode:5`, NEVER `code:'SQLITE_BUSY'`; `errcode & 0xff` → 5 busy / 6 locked, so extended 261 SQLITE_BUSY_RECOVERY→5; dup-key 1555→19 stays permanent) → pg SQLSTATE (exact set + whole `08` connection class, 5-char-gated) → driver class NAMES (cassandra `NoHostAvailableError`/`OperationTimedOutError`, couchbase `*TimeoutError`/`RequestCanceledError`/`ServiceNotAvailableError`, mongo `MongoNetworkError`/`*SelectionError`) → mongo error LABELS (`TransientTransactionError`/`RetryableWriteError`) → mongo numeric codes (**7 HostNotFound EXCLUDED** = server-side ENOTFOUND; 6 HostUnreachable kept) → couchbase N1QL `cause.first_error_code` 1080 + 12008 bulk-get-retry-exhaustion + `cause.retry===true` (**12009 generic-DML EXCLUDED** = also covers dup-key; the SDK buckets every un-cased 12xxx — permanent 12003 included — into one IndexFailureError grab-bag, so the cause CODE is the signal and that class name must never join the name table; the #B153 envelope-forward reaches classify as a synthesized name-`Error` wrapper WHENEVER THE ENVELOPE CARRIES TEXT, making the code table the SOLE classifier on that path — the 1080 entry is live there, dead only on the raw-SDK shape where the typed timeout name fires first). **#B153 RESIDUAL (fixed 0.5.26)** — the synthesize branch was gated on `typeof(cause.first_error_message) != 'undefined'`, but the node binding marshals `first_error_code`/`first_error_message` onto EVERY query error (the C++ `query_error_context` value-initializes them), so a CLIENT-side SDK timeout — `cause` present, code `0`, message `''` — took the synthesize branch and became `new Error('')`, destroying the typed `Un/AmbiguousTimeoutError` name `classify` matches on, and a retryable timeout was stamped PERMANENT with message `''`. Guard gains `&& cause.first_error_message !== ''` at all three couchbase N1QL `onError` sites, so an empty envelope raw-forwards instead: raw-forwarding is classification-LOSSLESS (the raw error keeps `.cause`, so even a pathological empty-message SERVER envelope still classifies off the code table — the synthesize branch only ever existed to surface envelope text). Named residual: a NON-Error rejection carrying an empty message still loses `.cause` on the `new Error(String(err))` wrap — no known producer (the SDK rejects with typed Errors, and the empty-message case IS the typed timeout). Server-side — NO dist rebuild; restart to pick up. Tests: `couchbase-connector.test.js §08` (14 — source pins on the new clause + client-timeout end-to-end replicas asserting the typed name survives and `classify().isTransient === true`, plus a pre-residual subtract control reproducing the false negative). `classify` is TOTAL — never throws (invoked on the error path; hostile shapes → permanent); `stamp` is frozen-safe. Registered PLAIN `require` (gen-0-captured by the load-once connectors + invoked per-request → a `_require` copy would be a #B32-residual leak candidate). Couchbase is classifiable ONLY because #B153 (`3ac5ea4a`) forwards the RAW driver error its old `.catch` used to discard (and used to HANG on when there was no `cause`). Declared on `GinaLib` in `types/index.d.ts` (the types-runtime-parity two-way gate). **Slice 2 SHIPPED (0.6.2+): opt-in 503 + `Retry-After`.** Per-bundle `server.transientErrors` block — `enabled` (strict boolean true, default false), `retryAfter` (integer seconds 1..86400, default 30), `message` (non-empty string, default = the standard 503 status text). With the opt-in ON, a stamped-transient error resolving to 500 through controller `throwError` renders 503 + `Retry-After: <retryAfter>` (one early guarded `setHeader` rides every egress: the JSON writeHead trio, the HTML fallback page, and the renderCustomError → render pipeline, which forwards `res.getHeaders()` into the HTTP/2 frame), with the user-facing `error` field swapped to `message`; `message`/`stack` body fields keep their existing scope semantics and the incident-ref pairing line keeps full pre-strip detail server-side. Explicit non-500 statuses and permanent errors never upgrade; upgrade applies at BOTH throwError status-resolution sites (the Error/1-arg path and the 2-arg errorObj path). Boot-time shape check warns per malformed key and NEVER refuses boot (a rendering nicety must not gate a boot); the controller-side reader independently normalizes to the same defaults, total (hostile getters → defaults). Deliberately NOT mirrored in the server-side throwError twin — stamped connector errors surface through controller actions only. Wire-verified live on BOTH protocols (isaac engine): h1 503+header, h2 `:status` 503 + `retry-after` (the renderJSON h2-inert-status class does not apply — throwError writes via writeHead; express shares the same engine-agnostic controller path, mechanism-verified only). Deferred: slice 3 (opt-in single auto-retry keyed on the entity OPERATION, not the serving request's HTTP verb — a GET can issue mutating queries). Server-side — NO dist rebuild; restart to pick up. Tests: `test/core/couchbase-connector.test.js §08` (#B153) + `test/lib/connector-error.test.js` (71) + `test/core/transient-errors.test.js` (26: source/order pins both files, real-throwError behaviorals via createTestInstance, extracted-bytes config-normalization matrix, server-twin divergence invariant).
|
|
1001
|
-
280. **Framework control-plane bind host + command-name namespace (0.5.26).** The two control-plane listeners — the command socket (default `8124`, `bin/cmd`, which executes a CLI command per connection) and the MQ listener (default `8125`, `lib/logger/src/containers/mq/listener.js`) — bind an explicit host that defaults to loopback. The `bind_host` setting governs it (env `GINA_BIND_HOST`, flag `gina framework:set --bind-host=`, seeded into `~/.gina/<shortVersion>/settings.json` and carried across shortVersion bumps by the `checkIfSettings` migration map); it is deliberately SEPARATE from `host_v4`, which is the address clients CONNECT to — reusing one key for both would make a workstation whose `host_v4` points at another machine fail to bind its own daemon (EADDRNOTAVAIL). Node binds every interface when `listen()` gets no host argument, and the MQ listener reads `opt.hostV4` specifically, so a caller passing a `host` key leaves it undefined and binds everything — both now default to `127.0.0.1` at the listener itself as well as at the resolution site. Exposing the control plane beyond the local host is a deliberate opt-in, mirroring a bundle's `--http-host`. Separately, `lib/cmd/framework/init.js` `run()` constrains a caller-supplied `topic:action` pair to `/^[A-Za-z0-9_-]+$/` before it becomes a `require()` path (an out-of-namespace segment resolves to `''`, which never exists, so it takes the same "not a valid command" branch); every shipped command group and action matches, including the synthetic `framework:-V` / `framework:--version` forms. An unresolvable name reaching the daemon over the socket ends that client's connection instead of calling `process.exit(1)`, which would stop the daemon serving every other client — an offline one-shot CLI run still exits non-zero. NOTE most command handlers call `process.exit()` themselves, so only `bundle:start` (the sole command `bin/cli` routes online) is designed to run inside the daemon. ⚠️ 0.5.26 shipped a DIAL-side regression with this: CLI-side clients — the command socket dial (`bin/cli`), the MQ speaker and file log containers, and `gina tail` — dialled `host_v4` while the daemon binds `bind_host`, so a co-located CLI could not reach its own daemon whenever `host_v4` was a non-loopback address of the same machine (the common containerized shape: `bundle:start` aborted before the HTTP server ever bound, and `gina tail` printed `[MQTail] ECONNREFUSED <host_v4>:8125`). Fixed post-0.5.26: all four dial sites resolve through `lib/net-locality` — `resolveDialHost(hostV4, bindHost)` dials the bind address (loopback by default, or `bind_host` when it is a concrete non-wildcard address) whenever `host_v4` names one of the machine's own interfaces per `os.networkInterfaces()`, while hostnames, genuinely remote addresses, and enumeration failures dial unchanged (fail-safe) — and the refused-connection error now names the dial target plus the `bind_host` knob. Also fixed in the same change: a `gina framework:set --bind-host=` write now PERSISTS — `bind_host` was the only connection key without a disk-to-env read-back in the settings regeneration (every CLI invocation rewrote it back to env-or-default), and the container bootstrap (`gina-init`) overwrote it unconditionally; both paths now preserve the persisted value, with `GINA_BIND_HOST` still taking precedence and unresolved `${}` template placeholders skipped. ⚠️ **A THIRD defect on this listener, fixed post-0.6.3 (#B279): its accepted connection sockets carried no `error` handler, so a departed speaker killed the whole command.** Because EVERY `bin/cli` invocation starts the listener on the shared MQ port, commands running concurrently connect to one another as speakers — and when one ends or is killed, the listener still holds its socket in `sessions`. The next write to it (the handshake write, or `self.report()`'s `sessions[sessionId].write()`) raises EPIPE/ECONNRESET **on the connection socket**, which node THROWS when unlistened, dropping the process mid-command: a `bundle:add` died this way after registering its ports but before copying and token-substituting the boilerplate, leaving a half-created bundle whose `index.js` was missing or still carried a literal `${bundle}`. **`server.on('error')` is NOT a substitute — in node every accepted connection is its own EventEmitter**, which is exactly why the pre-existing server-level handler left the hole open (a two-arm probe confirms it never fires for a connection-socket error). The handler is registered BEFORE the handshake write (itself a throwing site) and deletes `sessions[this.sessionId]` — an abruptly-killed peer may never emit `end`, which is what otherwise clears the entry and would leave a dead socket for the next `report()`. Generalisable: a `net.createServer` connection handler that registers `data`/`end` but not `error` is a process-kill waiting for a peer to vanish, and the symptom surfaces far from the socket — as a command that silently did only part of its work.
|
|
1001
|
+
280. **Framework control-plane bind host + command-name namespace (0.5.26).** The two control-plane listeners — the command socket (default `8124`, `bin/cmd`, which executes a CLI command per connection) and the MQ listener (default `8125`, `lib/logger/src/containers/mq/listener.js`) — bind an explicit host that defaults to loopback. The `bind_host` setting governs it (env `GINA_BIND_HOST`, flag `gina framework:set --bind-host=`, seeded into `~/.gina/<shortVersion>/settings.json` and carried across shortVersion bumps by the `checkIfSettings` migration map); it is deliberately SEPARATE from `host_v4`, which is the address clients CONNECT to — reusing one key for both would make a workstation whose `host_v4` points at another machine fail to bind its own daemon (EADDRNOTAVAIL). Node binds every interface when `listen()` gets no host argument, and the MQ listener reads `opt.hostV4` specifically, so a caller passing a `host` key leaves it undefined and binds everything — both now default to `127.0.0.1` at the listener itself as well as at the resolution site. Exposing the control plane beyond the local host is a deliberate opt-in, mirroring a bundle's `--http-host`. Separately, `lib/cmd/framework/init.js` `run()` constrains a caller-supplied `topic:action` pair to `/^[A-Za-z0-9_-]+$/` before it becomes a `require()` path (an out-of-namespace segment resolves to `''`, which never exists, so it takes the same "not a valid command" branch); every shipped command group and action matches, including the synthetic `framework:-V` / `framework:--version` forms. An unresolvable name reaching the daemon over the socket ends that client's connection instead of calling `process.exit(1)`, which would stop the daemon serving every other client — an offline one-shot CLI run still exits non-zero. NOTE most command handlers call `process.exit()` themselves, so only `bundle:start` (the sole command `bin/cli` routes online) is designed to run inside the daemon. ⚠️ 0.5.26 shipped a DIAL-side regression with this: CLI-side clients — the command socket dial (`bin/cli`), the MQ speaker and file log containers, and `gina tail` — dialled `host_v4` while the daemon binds `bind_host`, so a co-located CLI could not reach its own daemon whenever `host_v4` was a non-loopback address of the same machine (the common containerized shape: `bundle:start` aborted before the HTTP server ever bound, and `gina tail` printed `[MQTail] ECONNREFUSED <host_v4>:8125`). Fixed post-0.5.26: all four dial sites resolve through `lib/net-locality` — `resolveDialHost(hostV4, bindHost)` dials the bind address (loopback by default, or `bind_host` when it is a concrete non-wildcard address) whenever `host_v4` names one of the machine's own interfaces per `os.networkInterfaces()`, while hostnames, genuinely remote addresses, and enumeration failures dial unchanged (fail-safe) — and the refused-connection error now names the dial target plus the `bind_host` knob. Also fixed in the same change: a `gina framework:set --bind-host=` write now PERSISTS — `bind_host` was the only connection key without a disk-to-env read-back in the settings regeneration (every CLI invocation rewrote it back to env-or-default), and the container bootstrap (`gina-init`) overwrote it unconditionally; both paths now preserve the persisted value, with `GINA_BIND_HOST` still taking precedence and unresolved `${}` template placeholders skipped. ⚠️ **A THIRD defect on this listener, fixed post-0.6.3 (#B279): its accepted connection sockets carried no `error` handler, so a departed speaker killed the whole command.** Because EVERY `bin/cli` invocation starts the listener on the shared MQ port, commands running concurrently connect to one another as speakers — and when one ends or is killed, the listener still holds its socket in `sessions`. The next write to it (the handshake write, or `self.report()`'s `sessions[sessionId].write()`) raises EPIPE/ECONNRESET **on the connection socket**, which node THROWS when unlistened, dropping the process mid-command: a `bundle:add` died this way after registering its ports but before copying and token-substituting the boilerplate, leaving a half-created bundle whose `index.js` was missing or still carried a literal `${bundle}`. **`server.on('error')` is NOT a substitute — in node every accepted connection is its own EventEmitter**, which is exactly why the pre-existing server-level handler left the hole open (a two-arm probe confirms it never fires for a connection-socket error). The handler is registered BEFORE the handshake write (itself a throwing site) and deletes `sessions[this.sessionId]` — an abruptly-killed peer may never emit `end`, which is what otherwise clears the entry and would leave a dead socket for the next `report()`. Generalisable: a `net.createServer` connection handler that registers `data`/`end` but not `error` is a process-kill waiting for a peer to vanish, and the symptom surfaces far from the socket — as a command that silently did only part of its work. ⚠️ **A FOURTH defect on this subsystem, fixed post-0.6.5 (#B320): the two INTRA-HOST MQ clients — the speaker and the file log container — dialled `host_v4` VERBATIM whenever it was not a local address, shipping every runtime log frame to whatever machine the state named.** `host_v4` is self-describing advertisement state (“the address EXTERNAL clients use to reach this machine”), and on a `~/.gina` shared across hosts (each host rewriting `settings.json` at boot) or after a stale address is left behind, it names ANOTHER machine — the connect then SUCCEEDS against that machine's widened listener, so every boot line reads healthy while the local tail, file sink and container stdout receive nothing. 0.5.25-era deployments escaped only by accident: the speaker's settings `require()` typically threw (no GINA_HOMEDIR tier yet) and fell back to loopback. The dial now goes through `netLocality.resolveLocalDialHost(bindHost)` — bind side only, env first (`GINA_BIND_HOST`, then `settings.bind_host`): a concrete non-wildcard address of THIS machine is dialled, anything else (wildcard, absent, foreign, hostname, enumeration failure) stays on loopback — the deliberate INVERSE of `resolveDialHost`'s fail-unchanged rule, because an intra-host transport fails safe by refusing to leave the host (a loud local ECONNREFUSED beats silent cross-host delivery). The OPERATOR-FACING clients (`gina tail`, the command socket dial) deliberately keep the #B160 resolution, remote dial included — remote administration is unchanged; cross-host LOG shipping via `host_v4` was never a supported topology (default bind is loopback; the sanctioned remote-log stories are the Inspector agent endpoint and JSON-stdout aggregation). Deployment corollary: `~/.gina` is PER-HOST state — sharing one across hosts/pods races every boot's settings rewrite; give each its own `GINA_HOMEDIR`, and for containers `bin/gina-container` + `GINA_LOG_STDOUT=true` remains the recommended shape (it skips the MQ transport entirely).
|
|
1002
1002
|
|
|
1003
1003
|
281. **Production transport posture — `server.requireHttps` / `server.allowInsecure` (#COMPLY9, 0.5.26).** Outside the `local` scope a bundle resolving a cleartext scheme (anything but https — both engines' scheme switches default to the plain server) warns ONCE at boot, naming bundle/scheme/scope and both remediations; `server.requireHttps: true` refuses to boot it instead (the throw lands in init()'s central catch — pre-listen, the cleartext port never binds), and `server.allowInsecure: true` asserts TLS terminates upstream (mesh/ingress/reverse proxy — the documented h2c topology), turning the warn into one info line (same vocabulary as `mcp.json > server > allowInsecure`). Both strict booleans resolved like `scheme` itself (bundle settings win, env.json's `server` block fills via the serverOpt picked-keys merge); a non-boolean-when-present and the both-true contradiction refuse the boot in EVERY scope (the quietly-OFF class). The gate is NOT-local, never is-production — a custom scope (e.g. `beta`) is neither, and a production-keyed gate would silently skip it; `conf.server.scopeIsLocal` derives from def_scope, not the live scope, so it is never consulted. An https:// proxy.json upstream hostname is NAMED in the warn (advisory) but never silences it. Server-side only — no dist rebuild; pickup = restart.
|
|
1004
1004
|
|
|
@@ -1006,20 +1006,20 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
1006
1006
|
|
|
1007
1007
|
283. **Compliance posture — what Gina's controls map to, and the boundary no framework crosses (#COMPLY, 0.5.19–0.5.26).** Gina ships application-layer technical controls an application uses to SATISFY technical safeguards. It is not, and cannot be, "PCI-DSS compliant" / "SOC 2 compliant" / "HIPAA compliant" — and no framework can be: compliance is achieved by an ORGANIZATION, through a QSA assessment, an independent auditor's attestation, or a documented risk analysis plus Business Associate Agreements. There is no such thing as a certified open-source framework; assessors audit your application architecture and data handling, not the library under it. SHIPPED controls and the requirements they support: route authorization (`param.requireAuth`/`roles`/`policy`) plus the deny-by-default mode (`auth.requireAuthByDefault` + `param.public`) — PCI-DSS Req 7.2, SOC 2 CC6.3, HIPAA §164.312(a)(1); the append-only user-attributed audit trail (`audit.enabled` + `self.audit()`, auto-recording authorization denials) with an optional tamper-evidence hash chain verified offline by `gina audit:verify` — Req 10.2 · **§10.3.4** (the chain is change-detection: it DETECTS post-hoc edits, not prevents them; for the prevent-modification clauses §10.3.2/§10.3.3 stream the trail to a WORM/Object-Lock store, which also covers the one adversary the chain cannot — the process holding the signing key), CC7.2, §164.312(b); security headers incl. CSP — Req 6.4.3, CC6.6; CSRF signed double-submit — Req 6.2.4, CC6.1; session-cookie hardening (`HttpOnly` default on, `SameSite` default lax, boot refusal on `SameSite=None` without `Secure`) — Req 6.2.4, CC6.1; session lifecycle (`req.login()` rotates the session id at login, destroying the pre-login record — the fixation defense — plus opt-in `absoluteTimeout`; idle expiry composes from cookie `maxAge` + the store record's TTL, both rolling, and YOU set the window) — Req 6.2.4 (fixation is an attack on an access-control mechanism) · Req 8.2.8 once the idle window is set, CC6.1; authentication hardening (`lib.authn`: scrypt+PHC hashing with argon2/bcrypt verify-only migration, `dummyVerify` closing the user-enumeration oracle, account lockout at the §8.3.4 defaults of 10 attempts / 30 minutes, RFC 6238 TOTP returning the absolute step `counter` for the caller's replay defence) — Req 8.3.2/8.3.4/8.4, CC6.1, §164.312(d); the `${secret:KEY}` resolver, fail-closed on an unset key — Req 8.6.2 (no hard-coded credentials); output escaping (nunjucks auto-escapes by default; swig needs `settings.swig.autoescape: true`, 0.5.25+) — Req 6.2.4 (XSS); parameterized connector queries — Req 6.2.4 (injection); dependency/CVE scanning — Req 6.3.1-6.3.2, CC7.1; transport posture (`server.requireHttps` refuses a cleartext boot pre-listen, `server.allowInsecure` acknowledges upstream TLS termination) — Req 4.2.1, CC6.7. NOT provided by any framework and out of scope by construction: the certification itself, legal instruments (BAAs / DPAs), network and physical security, personnel and process, incident-response / DR / SIEM operation, key-management infrastructure, and vendor risk — Gina EMITS the signals (structured logs, the audit trail, Prometheus metrics); operating on them is the deployment's job. DEFERRED — demand-gated rather than dated, therefore still NOT controls you have: production log-field redaction (interim: keep credentials and personal identifiers out of query strings — the always-on access log records full request URLs; headers/body/cookies are not logged on any always-on path), application-level rate limiting (interim: throttle at the reverse proxy / ingress / WAF; `lib.authn` lockout is per-ACCOUNT credential braking, not per-client request volume), and field-level encryption (interim: your platform's KMS SDK, with `${secret:KEY}` delivering the key material). Each gets built when a consumer asks. A deferred control is not a control you have. Full per-control and per-standard mapping: the "Compliance control mapping" guide at https://gina.io/docs/guides/compliance, and its deployment-shaped companion at https://gina.io/docs/guides/zero-trust.
|
|
1008
1008
|
|
|
1009
|
-
284. **Couchbase session-store `touch()` — an idle-check that compared milliseconds against seconds, and why fixing only the units would have been worse (#B165, 2026-07-26,
|
|
1009
|
+
284. **Couchbase session-store `touch()` — an idle-check that compared milliseconds against seconds, and why fixing only the units would have been worse (#B165, 2026-07-26, 0.6.0).** Both the SDK-3 and SDK-4 stores gated their `lastModified` re-stamp on `timeElapsed > ttl`, where `timeElapsed` was a millisecond difference (`Date.getTime()` minus the parsed stamp) while `ttl` was in SECONDS — so the branch fired ~1000x sooner than its wording implied (86.4s idle on the 86400s default; 3.6s against a one-hour `cookie.maxAge`). The trap is that the obvious repair — converting the units — is a REGRESSION: the `upsert` below refreshes the document's expiry on EVERY touch unconditionally, so the stamp must track every extension; gating it on `ttl` seconds of idleness means firing only after the session has already expired, freezing `lastModified` at the first `set()`. That stamp is not decorative — the browser reads it (`gina.session.lastModified`) and derives the live session countdown as `lastModified + originalTimeout - now`, so a frozen origin inflates the remaining time shown to the user. FIX: delete the idle-check outright and stamp unconditionally, keeping only the meaningful `ttl > 0` guard that `set()` already used — removing the defect by construction rather than by arithmetic. The SDK-4 store additionally stamped through a zone-less local-time mask (`yyyy-mm-dd'T'HH:MM:ss`), which the browser re-parses as BROWSER-local and so skewed the countdown by the server/browser offset; both stores now use `toISOString()`, matching the redis / sqlite / mongodb / scylladb stores. The `touchAfter` option the old comment referenced never existed anywhere in the codebase (imported wholesale from connect-mongo). Server-side only — no dist rebuild; a bundle restart picks it up. Rule worth carrying: when a stored value is the origin of a client-visible computation, throttling its writes is not an optimisation, it is a correctness bug.
|
|
1010
1010
|
|
|
1011
|
-
285. **Session-store ttl vs cookie maxAge — the implicit one-day default that shadowed the cookie (#B163, 2026-07-27,
|
|
1011
|
+
285. **Session-store ttl vs cookie maxAge — the implicit one-day default that shadowed the cookie (#B163, 2026-07-27, 0.6.0).** The redis/sqlite/mongodb/scylladb session stores defaulted the constructor ttl to `connConf.ttl || oneDay`, so `this.ttl` was truthy (86400) in every ordinary configuration and the byte-identical `set()`/`touch()` fallback `var ttl = this.ttl || ('number' === typeof maxAge ? maxAge / 1000 | 0 : oneDay)` never reached the cookie's `maxAge` — a 1-hour cookie left its record alive server-side for 24h (orphan records, and a leaked session id stayed valid long after its cookie died — the id is whispered into every rendered page), while a 7-day cookie was silently cut to 24h. Fixed by keeping nothing-configured null (`connConf.ttl || null`), converging all six stores on the couchbase constructors' `options.ttl || null`: unset ttl → the record follows the cookie's maxAge → one day only when the cookie has none; an explicit ttl (store options or the connector entry's `ttl`) still wins, and `{ttl: 0}` still means defer-to-maxAge (an explicit connector `ttl: 0` now defers too, instead of being rewritten to a day). The change moves record lifetimes in BOTH directions vs the old accidental 24h cap — that cap was an accidental default, not a security control. Tests: `test/core/session-store-ttl.test.js` executes the shipped constructor + set()/touch() expressions extracted from the source (extraction control-gated), red-first validated 17-fail against the pre-fix bytes. Consequence worth knowing: making the cookie-`maxAge` fallback reachable also made a NON-POSITIVE resolved ttl reachable (the getter decays), which activated a latent missing guard in the sqlite store's `touch()` — see #287. #B207 (2026-08-02) closes the pair: constructors now REFUSE a non-positive configured ttl at bundle init on both channels (store options + the connectors.json session entry; couchbase reads options only, stated in the schema `ttl` description), and every set() carries the touch() guard — a resolved ttl <= 0 no-ops instead of reaching the backend, where the camps split OPPOSITE ways (redis wrote an expiry-less plain SET, couchbase upserted a zero expiry, scylladb `USING TTL 0` — all IMMORTAL records for an already-expired session — while mongodb/sqlite wrote an instantly-dead row); couchbase touch() gains the guard it alone lacked, and redis's plain-SET branch is gone. A configured `ttl: 0` previously collapsed to "unset" via double truthiness on every path — it never reached any backend — so the refusal breaks nothing that ever worked.
|
|
1012
1012
|
|
|
1013
|
-
286. **`req.logout()` destroys the session record — the gina-native logout shim's missing terminal (#B164, 2026-07-27,
|
|
1013
|
+
286. **`req.logout()` destroys the session record — the gina-native logout shim's missing terminal (#B164, 2026-07-27, 0.6.0).** The per-request logout shim (`core/router.js`, installed inside `route()` under the Passport-HTTP2-fix block) only set `request.session.user = null` on its gina-native branch — the request de-authenticated (the authorization gate reads that key), but there was no `destroy()`, no rotation, no cookie clear: the store record, the session id (whispered into every rendered page) and every other session key (cart, flash, `inheritedData`) survived to TTL. The gina-native branch now also calls the session's own `destroy()` when it exposes one (express-session's `Session.prototype.destroy` deletes `req.session` synchronously, then destroys the store record), degrades gracefully when it does not (user still cleared, callback still fires), and takes an optional `done(err)` callback honoured on every path. The destroy is gated on the session scope, so a call after destruction (no `request.session` left) can never reach `request.destroy()` — the stream method. Install-guard arms and the Passport branch are byte-untouched: the shim never installs for a normally-initialized Passport bundle (`passport.initialize()` sets `req.logOut` + `req._passport` before `router.route()` runs), and Passport ≥ 0.6 already destroys the record via its own `regenerate()`. Cookie clearing stays the consumer's job (the cookie name is not discoverable from the request — expire it yourself after logout), and session-id rotation on privilege change is separate hardening. Tests: `test/core/router-logout.test.js` brace-match-extracts the shipped shim and drives it behaviourally (extraction control-gated), red-first validated 7-fail against the pre-fix bytes.
|
|
1014
1014
|
|
|
1015
|
-
287. **SQLite session-store `touch()` stamped a past expiry — the missing non-positive-ttl guard (#B166, 2026-07-27,
|
|
1015
|
+
287. **SQLite session-store `touch()` stamped a past expiry — the missing non-positive-ttl guard (#B166, 2026-07-27, 0.6.0).** `SqliteStore.prototype.touch()` computed `expires = Math.floor(Date.now()/1000) + ~~ttl` and wrote it unconditionally, with no check that the resolved ttl was positive. It is reachable because express-session's `cookie.maxAge` is a GETTER returning `this.expires.valueOf() - Date.now()` — a decaying remainder, not the configured constant — so the shared `maxAge / 1000 | 0` fallback truncates to `0` in a session's final second and goes negative once the cookie has expired; sqlite then stamped an at-or-before-now expiry, i.e. the `touch()` meant to REFRESH the session ended it instead. Its three connectors.json-driven siblings all guarded already — redis as the inverted `if (ttl > 0) { … } else { fn(); }`, mongodb and scylladb as `if (ttl <= 0) return fn(null);` — so sqlite was the lone outlier; fixed by adding that same guard BEFORE the expiry is computed or written. Latent until #B163: while an unset ttl still resolved to a truthy `86400` the maxAge branch never ran, so a non-positive ttl was unreachable in ordinary configurations — making the cookie fallback reachable is what activated this. Same commit corrects the published `connectors.json` JSON-schema declarations, which labelled `ttl` and `prefix` "Redis only" (measured: `ttl` is read by redis+sqlite+mongodb+scylladb, `prefix` by redis+sqlite, and the couchbase stores read BOTH as constructor options only — they read nothing from connectors.json but the `connector` key the dispatcher selects on) and still advertised `"default": 86400` for `ttl`, false since #B163 made an unset ttl defer to the cookie. Tests: `test/core/session-store-touch-guard.test.js` brace-matches all four shipped `touch()` bodies out of the source and executes those exact bytes (extraction control-gated on exactly-one-declaration + balanced braces + a known-negative), red-first validated 6-fail against the pre-fix bytes — with the three already-guarded siblings passing the identical behavioural assertions as the built-in control that the harness discriminates.
|
|
1016
1016
|
|
|
1017
|
-
288. **Session lifecycle — `req.login()` rotation + the opt-in absolute timeout (#COMPLY4, 2026-07-27,
|
|
1018
|
-
289. **Couchbase session store requires `options.db` — the self-connect fallback was dead code (#B167, 2026-07-27,
|
|
1017
|
+
288. **Session lifecycle — `req.login()` rotation + the opt-in absolute timeout (#COMPLY4, 2026-07-27, 0.6.0).** Two halves. (1) `req.login(user, done)` now works for gina-native bundles: the router shim's non-Passport path — previously a hard `passport.initialize()`-required throw, after setting only `request.user`, which nothing reads (the authorization gate reads `req.session.user`) — now rotates the session id BEFORE binding the user (the session-fixation defense): regenerate (destroying the pre-login record), bind at `session.user`, stamp the `_ginaCreatedAt` absolute-timeout anchor, persist via the session's own `save()`, then the REQUIRED `done(err)`. Degrades bind-and-warn when the provider lacks `regenerate()`; errors name the Session plugin when no session exists; the Passport branch is byte-untouched (Passport's own `req.login` wins whenever passport.initialize ran). Key design fact: rotation needed NO store code — express-session's base `Store.prototype.regenerate` (destroy + generate) is inherited by all six gina session stores; only the call site was missing. Pre-login session data is destroyed by design (read-then-re-set to carry a value across); CSRF tokens self-heal (they are HMACs over the CURRENT sid, re-issued on verify-failure). (2) The Session plugin gained `session({ absoluteTimeout: <ms> })` — or `settings.json > session.absoluteTimeout` as the deployment default, bundle code winning and `false` there disabling — capping an authenticated session's total lifetime from login regardless of activity: an over-age session is destroyed on its next request via its own `regenerate()` and the request proceeds with a fresh anonymous session (indistinguishable from natural expiry, so authz paths need nothing); fail-closed (auth dropped locally even when the store errors); anonymous sessions never stamped, preserving `saveUninitialized: false`; without the option the factory returns express-session's middleware IDENTICALLY. Idle expiry already existed and composes: the cookie `maxAge` and the store-record TTL both roll with activity (coherent since #285's ttl fix). Declared in the published settings.json schema. Tests: `test/core/router-login.test.js` (brace-match extraction of the shim, driven as real bytes, red-first 11-fail) + `test/core/session-plugin-absolute-timeout.test.js` (the plugin is require-able — genuinely behavioural, incl. the no-option identity subtract and the code-wins value proven by behaviour; red-first 12-fail); live-verified on a daemonless boot with real express-session: sid rotated, old-cookie replay anonymous under a fresh sid, absolute cap enforced with a before/after control.
|
|
1018
|
+
289. **Couchbase session store requires `options.db` — the self-connect fallback was dead code (#B167, 2026-07-27, 0.6.0).** The store never configures itself from connectors.json (the factory reads only the entry's `connector` key to pick the store class): the connection arrives through the constructor — `new CouchbaseStore({ db: getModel('session').getConnection() })` — as the already-open bucket the model layer created from the `session` connector entry at boot, and the store writes through `db.defaultCollection()`. The former no-`db` fallback called `cluster.openBucket()`, an SDK v2 API absent from the supported v3/v4 SDKs, so reaching it always threw at bundle init; it was removed along with the options that only fed it (`host`/`hosts`/`username`/`password`/`bucket`/`cachefile` — and note the connector ENTRY's bucket-name key is `database`, never `bucket`), and a missing or wrong-shaped `db` now fails fast with an actionable error. The guard also catches the wrong-`getConnection` trap: a model's zero-arg `getConnection()` returns the bucket (a valid `db`), while an entity's `getConnection(scope, collection)` drills to a collection, which has no `defaultCollection()`. The connectors.json entry's key must be `session` — the factory resolves it from express-session's read-only function name, so assigning `session.name` cannot re-key it. `ttl`/`prefix`/`connectionTimeout`/`operationTimeout` remain constructor options (#287 carries the ttl-labels sibling story).
|
|
1019
1019
|
|
|
1020
|
-
290. **`lib/authn` — password hashing, account lockout and RFC 6238 TOTP (#COMPLY3, 2026-07-27,
|
|
1020
|
+
290. **`lib/authn` — password hashing, account lockout and RFC 6238 TOTP (#COMPLY3, 2026-07-27, 0.6.0).** Hooks and helpers, never an identity provider: gina owns no user record, credential store, login route or session id, so the module ENDS where `req.login()` begins (#288) and `lib/authz-gate` takes over from `req.session.user`. The composition a bundle writes is `validatePasswordPolicy(pw)` at registration/change → `createLockout().check(key)` BEFORE spending a KDF → `verifyPassword(pw, stored)`, pairing `dummyVerify` on the account-not-found branch → `req.login(user, done)`. **Hashing** is scrypt (node core, memory-hard, no native addon) encoded as a self-describing PHC string `$scrypt$ln=17,r=8,p=1$<salt>$<key>`, so the cost travels with the hash and raising it is not a flag day: `needsRehash(stored)` reports what is below policy and the next successful login upgrades it transparently — the only moment the plaintext is in hand. `verifyPassword` ALSO verifies `$argon2*$` and `$2a/2b/2y$` through the CONSUMING PROJECT's own package (the connector project-path pattern), so a bundle arriving with existing hashes keeps working; gina never MINTS those — one mint path, one parameter set. **`dummyVerify` must always be given `{like: <a stored hash>}`**: it has no stored hash to read a cost from, so at the shipped default against cheaper hashes the unknown-account branch measured 13.9x SLOWER than the known one — an INVERTED enumeration oracle, louder than doing nothing. Both it and `verifyPassword` surface the same `AUTHN_QUEUE_FULL` shed error, because any divergence between the two branches under saturation is itself the enumeration signal. Policy is length-first per NIST SP 800-63B (12 chars, no mandatory character classes) returning stable codes (`too-short`, `denied-substring`, …); passwords are NFC-normalised on both sides so a device-encoding difference cannot lock a user out of their own account. **Lockout** defaults to PCI-DSS v4.0.1 §8.3.4 (lock after 10 consecutive failures, for 30 minutes; `reset()` is the standard's "or until identity is confirmed" half), keys on a caller-chosen string because gina cannot derive one, and SERIALIZES operations per key — the counter update is a read-modify-write across an async store call, so without it 20 concurrent failures against a threshold of 5 recorded ONE attempt and never locked. Pass `normalizeKey` whenever the key is user-supplied or case variants each get their own counter and multiply the threshold; the default store is per-process, so pass a shared `store` (the same callback `get`/`set`/`del` contract the job and audit seams use) for multi-replica correctness — otherwise the ceiling degrades to roughly `maxAttempts` per replica. One `auth.lockout` audit record is written on the TRANSITION into locked, never per attempt. **Lockout is not rate limiting**: per-ACCOUNT credential failures vs per-CLIENT request volume — different axes, neither substitutes, and request-rate limiting is tracked separately on the roadmap. **TOTP** returns the matched step as an absolute `counter`; the consumer persists it and requires the next accepted counter to be strictly greater, or an observed code stays usable for its whole acceptance window — the RFC assigns replay defence to the verifier and gina has nowhere to write. A malformed code is `{valid:false}` (same shape as a wrong one), while an unusable secret throws — a configuration fault, not a login attempt. Registered plain-`require`, not `_require`: it holds the KDF concurrency gauge and its FIFO queue at module scope, so a dev-mode hot reload would strand in-flight logins, and a security primitive stays out of the reload path on principle. Declared on `GinaLib` in `types/index.d.ts` — the two-way runtime-parity gate reds the whole suite otherwise, with a message naming only the key. Server-side only: no dist rebuild, pickup is a bundle restart.
|
|
1021
1021
|
|
|
1022
|
-
291. **A framework-produced falsy proxy-host state must degrade the URL builder, never crash it — and the degrade must flip the flag `toUrl()` keys on (#B168, 2026-07-28,
|
|
1022
|
+
291. **A framework-produced falsy proxy-host state must degrade the URL builder, never crash it — and the degrade must flip the flag `toUrl()` keys on (#B168, 2026-07-28, 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, while 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). Fixed with a truthiness guard that 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 needed the same coordination or they would undo it: their per-request override re-forced the flag with an unresolved value in slot-less renders, so they now hold `getRoute()`'s own resolution and degrade only when nothing resolved anywhere (this also stops the override from replacing a usable envConf-derived value with an unset one). The non-proxied branch's `typeof` gate admitted a defined-but-falsy global (typeof null is 'object') and now requires truthy. Browser bundle changed (`lib/routing` ships in it) with zero client behavior change — the client arm resolves from `window.location`, which cannot be unset.
|
|
1023
1023
|
|
|
1024
1024
|
292. **SQLite under Bun — every framework SQLite consumer resolves its driver through one seam (`lib/sqlite-driver`), and the Bun adapter stamps the node-shaped numeric `errcode` onto bun errors.** Bun implements no `node:sqlite` (`No such built-in module`, measured on bun 1.2.21 + 1.3.14; upstream request oven-sh/bun#20412 open), so the sqlite ORM connector, session store and async-job store previously failed at boot under Bun while the framework state store silently degraded to JSON. `lib/sqlite-driver.getDatabaseSync()` tries `node:sqlite` FIRST (the Node path returns the native `DatabaseSync` verbatim — zero Node delta, and the adapter self-retires if a future Bun ships `node:sqlite`), else under Bun wraps `bun:sqlite.Database` in a `DatabaseSync`-shaped adapter covering exactly the surface the framework uses — constructor / `exec` / `prepare` → `get`/`all`/`run` with positional params / `close` — measured 13/13 parity on both bun versions (file creation, WAL pragmas, `null` bindings, `{changes, lastInsertRowid}` both numbers, `get()` miss → `undefined`, expando props on the handle, use-after-close throws). The one real divergence is ERROR SHAPE: bun throws `SQLiteError` with the numeric SQLite result code on `errno` plus a specific string `code` (e.g. `SQLITE_BUSY`), where node throws `Error` with `code: 'ERR_SQLITE_ERROR'` and the number on `errcode` — the adapter mirrors `errno` → `errcode` on every rethrown error so the transient/permanent classifier's numeric branch (`errcode & 0xff`) reads both drivers identically, extended codes included (a raw bun `SQLITE_BUSY_RECOVERY` string would miss the exact-string table and misclassify as permanent). Rule: any new SQLite consumer must resolve through the seam, never `require('node:sqlite')` directly (pinned per consumer). Bun boundary that remains: the mongodb connector cannot load under Bun at all — its own `bson` dependency calls `v8.isBuildingSnapshot()`, unimplemented in Bun; that one has no framework-side fix.
|
|
1025
1025
|
293. **`new require(mod)(args)` is a Bun boot-crash for every connector, and the repair is the PLAIN call — not `new (require(mod))(args)`.** `new require(mod)(args)` parses as `(new require(mod))(args)`: `require` is invoked as a CONSTRUCTOR, and because a constructor returning an object yields that object, the expression evaluates to `module.exports`, which is then invoked as a PLAIN call. Node tolerates it (its `require` has a [[Construct]] slot); Bun's does not, so every such site throws `TypeError: function is not a constructor`. Measured 2026-07-29 on the same module under node:24 and oven/bun:1.3.14: `new require(p)(a,b)` works on Node / THROWS on Bun, `require(p)(a,b)` works on BOTH, and on Node the two forms returned IDENTICAL results — which is why the plain call is the zero-Node-delta repair and why `new (require(p))(a,b)` is WRONG (it would change Node semantics from a plain call to real construction, diverging for any connector that returns a primitive or nothing). Four live sites fixed: `lib/model.js` boot + reloadModels connector managers, `core/model/template/index.js`, and the `core/server.js` multipart liner. The sibling break behind it: `core/model/entity.js` reassigned `arguments`, which V8 permits in sloppy mode but JSC rejects outright (`SyntaxError: Invalid assignment target`) — a local variable preserves the exact prior semantics — but ONLY for that scope's own two reads: the function body holds two MORE `arguments` reads nested inside `.on(evt, function(){...})` callbacks, where `arguments` is each callback's own binding, so rewriting those would be a silent behaviour change (a first pass rewrote all four; the cold-read-the-diff pass caught it pre-commit). Both were invisible for the whole Bun-support arc because NO CI leg — Node or Bun — exercised any connector; the SQLite leg of the container smoke found the first one on its first Bun run. Instrument lesson: a per-file `bun -e "require(f)"` sweep reported the entity file as parsing FINE (a stuck-FALSE reading); a `Bun.Transpiler` sweep over all 346 framework files was the known-positive control that located it, and a direct CJS require then reproduced the exact boot message.
|