gina 0.7.0 → 0.7.1
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 +39 -0
- package/README.md +45 -61
- package/ROADMAP.md +3 -2
- package/bin/cli +5 -2
- package/bin/gina +10 -25
- package/framework/v0.7.1/VERSION +1 -0
- package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/inspector/inspector.js +21 -8
- package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/js/gina.js +8 -11
- package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/js/gina.min.js +160 -160
- package/framework/v0.7.1/core/asset/plugin/dist/vendor/gina/js/gina.min.js.br +0 -0
- package/framework/v0.7.1/core/asset/plugin/dist/vendor/gina/js/gina.min.js.gz +0 -0
- package/framework/{v0.7.0 → v0.7.1}/core/config.js +10 -1
- package/framework/{v0.7.0 → v0.7.1}/core/controller/controller.js +67 -15
- package/framework/{v0.7.0 → v0.7.1}/core/router.js +68 -8
- package/framework/{v0.7.0 → v0.7.1}/core/server.isaac.js +183 -45
- package/framework/{v0.7.0 → v0.7.1}/core/server.js +173 -55
- package/framework/{v0.7.0 → v0.7.1}/helpers/context.js +93 -32
- package/framework/{v0.7.0 → v0.7.1}/helpers/path.js +61 -43
- package/framework/{v0.7.0 → v0.7.1}/helpers/task.js +47 -35
- package/framework/v0.7.1/lib/admin/src/main.js +451 -0
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/add.js +24 -0
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/copy.js +14 -0
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/help.txt +11 -0
- package/framework/v0.7.1/lib/cmd/bundle/inc/boot-lines.js +263 -0
- package/framework/v0.7.1/lib/cmd/bundle/inc/name.js +70 -0
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/remove.js +8 -2
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/rename.js +14 -0
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/start.js +47 -12
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/stop.js +64 -8
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/env/remove.js +7 -2
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/add.js +37 -2
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/build.js +20 -5
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/dot.js +10 -4
- package/framework/v0.7.1/lib/cmd/framework/inc/ps-titles.js +113 -0
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/init.js +7 -2
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/link.js +10 -2
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/open.js +30 -6
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/restart.js +24 -69
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/start.js +6 -24
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/status.js +44 -25
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/tail.js +1 -1
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/helper.js +56 -13
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/inspector/help.txt +2 -1
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/inspector/open.js +93 -30
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/minion/kill.js +52 -25
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/port/list.js +15 -6
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/port/reset.js +11 -2
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/add.js +50 -1
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/help.txt +8 -8
- package/framework/v0.7.1/lib/cmd/project/inc/name.js +71 -0
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/remove.js +5 -1
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/rename.js +12 -0
- package/framework/v0.7.1/lib/cmd/project/restart.js +126 -0
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/restore.js +19 -0
- package/framework/v0.7.1/lib/cmd/project/start.js +125 -0
- package/framework/v0.7.1/lib/cmd/project/stop.js +98 -0
- package/framework/{v0.7.0 → v0.7.1}/lib/cmd/protocol/set.js +7 -2
- package/framework/{v0.7.0 → v0.7.1}/lib/inherits/src/main.js +8 -11
- package/framework/{v0.7.0 → v0.7.1}/lib/maintenance/src/main.js +3 -2
- package/framework/{v0.7.0 → v0.7.1}/lib/metrics/src/main.js +30 -6
- package/framework/{v0.7.0 → v0.7.1}/lib/shell.js +58 -25
- package/framework/{v0.7.0 → v0.7.1}/package.json +1 -1
- package/gna.js +4 -4
- package/llms.txt +13 -13
- package/package.json +4 -4
- package/schema/app.json +3 -3
- package/script/post_install.js +20 -2
- package/script/pre_install.js +31 -6
- package/utils/helper.js +21 -9
- package/framework/v0.7.0/VERSION +0 -1
- package/framework/v0.7.0/core/asset/plugin/dist/vendor/gina/js/gina.min.js.br +0 -0
- package/framework/v0.7.0/core/asset/plugin/dist/vendor/gina/js/gina.min.js.gz +0 -0
- package/framework/v0.7.0/lib/admin/src/main.js +0 -217
- package/framework/v0.7.0/lib/cmd/project/restart.js +0 -85
- package/framework/v0.7.0/lib/cmd/project/start.js +0 -85
- package/framework/v0.7.0/lib/cmd/project/stop.js +0 -66
- /package/framework/{v0.7.0 → v0.7.1}/AUTHORS +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/LICENSE +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/html/nolayout.html +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/html/static.html +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/img/android-chrome-192x192.png +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/img/android-chrome-512x512.png +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/img/apple-touch-icon.png +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/img/favicon-16x16.png +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/img/favicon-32x32.png +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/img/favicon.ico +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/beemaster/beemaster.css +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/beemaster/beemaster.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/beemaster/index.html +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/css/gina.min.css +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/css/gina.min.css.br +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/css/gina.min.css.gz +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/html/statusbar.html +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/html/statusbar.html.br +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/html/statusbar.html.gz +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/inspector/have_heart_one-webfont.woff2 +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/inspector/index.html +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/inspector/inspector.css +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/inspector/logo.svg +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/js/gina.onload.min.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/js/gina.onload.min.js.br +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/asset/plugin/dist/vendor/gina/js/gina.onload.min.js.gz +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/config.requirements-anchor.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/ai/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/ai/lib/connector.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/couchbase/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/couchbase/lib/connector.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/couchbase/lib/connector.v3.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/couchbase/lib/connector.v4.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/couchbase/lib/n1ql.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/couchbase/lib/session-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/couchbase/lib/session-store.v3.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/couchbase/lib/session-store.v4.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/couchbase/lib/storage-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/duckdb/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/duckdb/lib/connector.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/mongodb/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/mongodb/lib/connector.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/mongodb/lib/job-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/mongodb/lib/pipeline-loader.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/mongodb/lib/session-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/mysql/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/mysql/lib/connector.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/param-redact.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/postgresql/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/postgresql/lib/connector.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/redis/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/redis/lib/connector.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/redis/lib/job-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/redis/lib/kv-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/redis/lib/render-cache-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/redis/lib/session-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/scylladb/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/scylladb/lib/connector.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/scylladb/lib/session-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/settle-once.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/sql-parser.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/sqlite/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/sqlite/lib/connector.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/sqlite/lib/job-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/sqlite/lib/kv-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/connectors/sqlite/lib/session-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/content.encoding +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/controller.framework.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/controller.render-json.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/controller.render-nunjucks-async.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/controller.render-nunjucks.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/controller.render-stream.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/controller.render-swig-async.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/controller.render-swig.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/controller.render-v1.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/controller.render-xml.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/inline-script.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/inspector-window-emit.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/controller/release-banner.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/dev/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/dev/lib/class.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/dev/lib/factory.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/dev/lib/tools.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/gna.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/locales/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/locales/currency.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/locales/dist/language/en.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/locales/dist/language/fr.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/locales/dist/region/en.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/locales/dist/region/fr.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/locales/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/mime.types +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/model/entity.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/model/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/model/template/entityFactory.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/model/template/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/csrf/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/csrf/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/csrf/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/coep/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/coep/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/coep/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/coop/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/coop/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/coop/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/corp/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/corp/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/corp/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/csp/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/csp/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/csp/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/hide-powered-by/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/hide-powered-by/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/hide-powered-by/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/hsts/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/hsts/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/hsts/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/origin-agent-cluster/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/origin-agent-cluster/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/origin-agent-cluster/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/referrer-policy/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/referrer-policy/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/referrer-policy/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-content-type-options/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-content-type-options/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-content-type-options/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-dns-prefetch-control/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-dns-prefetch-control/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-dns-prefetch-control/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-download-options/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-download-options/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-download-options/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-frame-options/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-frame-options/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-frame-options/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-permitted-cross-domain-policies/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-permitted-cross-domain-policies/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-permitted-cross-domain-policies/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-xss-protection/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-xss-protection/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/security-headers/x-xss-protection/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/session/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/session/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/session/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/storage/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/storage/build.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/storage/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/storage/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/validator/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/validator/build.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/validator/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/validator/src/form-validator.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/plugins/lib/validator/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/server.express.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/server.isaac.rapid-reset.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/server.route-candidates.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/status.codes +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/_gitignore +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle/config/app.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle/config/connectors.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle/config/routing.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle/config/settings.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle/config/settings.server.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle/config/templates.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle/config/watchers.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle/controllers/controller.content.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle/controllers/controller.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle/controllers/setup.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle/locales/en.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_namespace/controllers/controller.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_public/css/default.css +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_public/css/home.css +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_public/css/vendor/readme.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_public/favicon.ico +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_public/js/components/x-checklist.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_public/js/vendor/readme.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_public/manifest.webmanifest +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_public/readme.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_public/sw.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_templates/handlers/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_templates/html/content/homepage.html +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_templates/html/includes/error-msg-noscript.html +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_templates/html/includes/error-msg-outdated-browser.html +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_templates/html/includes/x-checklist.html +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/boilerplate/bundle_templates/html/layouts/main.html +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/command/gina.bat.tpl +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/command/gina.tpl +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/conf/env.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/conf/manifest.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/conf/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/conf/settings.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/conf/statics.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/conf/templates.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/error/client/json/401.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/error/client/json/403.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/error/client/json/404.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/error/server/html/50x.html +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/error/server/json/500.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/error/server/json/503.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/core/template/extensions/logger/config.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/console.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/data/LICENSE +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/data/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/data/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/data/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/dateFormat.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/json/LICENSE +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/json/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/json/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/json/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/plugins/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/plugins/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/plugins/src/api-error.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/plugins/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/prototypes.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/helpers/text.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/admin/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/archiver/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/archiver/build.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/archiver/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/archiver/src/dep/jszip.min.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/archiver/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/async/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/async/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/audit/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/audit/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/audit-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/authn/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/authn/src/lockout.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/authn/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/authn/src/totp.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/authz-gate/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/authz-gate/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cache/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cache/build.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cache/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cache/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/aliases.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/audit/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/audit/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/audit/verify.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/build.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/cp.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/inc/name-rewrite.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/list.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/man.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/mcp-start.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/mcp.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/oas.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/openapi.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/restart.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/rm.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/status.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/bundle/types.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/cache/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/cache/clear.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/cache/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/cache/stats.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/connector/add.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/connector/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/connector/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/connector/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/connector/infer.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/connector/list.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/connector/migrate.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/connector/models.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/connector/remove.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/connector/rm.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/connector/test.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/container/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/container/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/container/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/container/man.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/container/ps.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/container/stop.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/controller/add.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/controller/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/controller/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/controller/inc/args.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/controller/inc/namespace.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/controller/inc/reference-rewrite.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/controller/inc/reference-scan.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/controller/inc/scaffold.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/controller/remove.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/controller/rename.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/controller/rm.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/env/add.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/env/get.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/env/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/env/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/env/inc/name.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/env/link-dev.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/env/list.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/env/rm.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/env/set.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/env/unset.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/env/use.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/get.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/link-node-modules.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/list.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/man.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/msg.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/remove.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/reset.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/set.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/stop.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/update.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/framework/version.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/gina-dev.1.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/gina-framework.1.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/gina.1.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/i18n/add.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/i18n/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/i18n/export.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/i18n/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/i18n/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/i18n/import.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/i18n/scan.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/image/_host.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/image/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/image/build.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/image/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/image/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/image/list.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/image/man.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/image/rm.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/image/run.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/inspector/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/man-render.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/minion/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/minion/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/minion/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/minion/list.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/msg.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/port/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/port/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/port/inc/scan.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/port/set.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/backup.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/build.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/import.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/list.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/man.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/move.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/rm.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/project/status.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/protocol/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/protocol/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/protocol/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/protocol/list.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/protocol/remove.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/scope/add.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/scope/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/scope/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/scope/inc/name.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/scope/link-local.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/scope/link-production.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/scope/list.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/scope/remove.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/scope/rm.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/scope/use.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/secrets/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/secrets/check.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/secrets/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/secrets/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/secrets/scan.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/service/help.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/service/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/service/list.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/service/man.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/service/start.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/storage/arguments.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/storage/gc.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/storage/help.txt +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/storage/stats.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/storage/verify.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd/view/add.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd-status-format/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cmd-status-format/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/collection/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/collection/build.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/collection/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/collection/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/conf-view/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/conf-view/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/config.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/connector-config/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/connector-config/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/connector-error/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/connector-error/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/connector-registry/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/connector-registry/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cron/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cron/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/cron/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/domain/LICENSE +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/domain/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/domain/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/domain/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/dto/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/dto/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/dto-pipe/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/dto-pipe/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/dto-types/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/dto-types/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/duration/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/duration/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/generator/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/i18n/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/i18n/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/idempotency/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/idempotency/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/image-build/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/image-build/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/inherits/LICENSE +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/inherits/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/inherits/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/inspector-events/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/inspector-events/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/inspector-redact/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/inspector-redact/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/instrument/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/instrument/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/job/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/job/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/job-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/json-config-header/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/json-config-header/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/kv/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/kv/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/kv-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/loading-state/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/loading-state/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/logger/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/logger/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/logger/src/containers/default/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/logger/src/containers/file/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/logger/src/containers/mq/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/logger/src/containers/mq/listener.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/logger/src/containers/mq/speaker.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/logger/src/helper.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/logger/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/logger/src/redact.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/maintenance/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/math/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/mcp-dispatch/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/mcp-dispatch/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/mcp-http/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/mcp-http/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/mcp-server/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/mcp-server/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/merge/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/merge/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/merge/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/message-validator/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/message-validator/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/metrics/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/model.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/money/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/money/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/multipart/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/multipart/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/net-locality/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/net-locality/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/nunjucks-filters/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/nunjucks-filters/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/nunjucks-filters/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/nunjucks-resolver/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/nunjucks-resolver/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/priority/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/priority/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/proc.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/push/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/push/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/rate-limit/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/rate-limit/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/release-watch/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/release-watch/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/render-cache/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/render-cache/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/render-cache-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/routing/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/routing/build.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/routing/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/routing/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/routing-introspect/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/routing-introspect/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/secrets/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/secrets/src/backends/env.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/secrets/src/backends/exec.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/secrets/src/backends/file.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/secrets/src/declaration.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/secrets/src/env-file.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/secrets/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/secrets/src/sources.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/security-headers-emitter/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/security-headers-emitter/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/session-lifetime/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/session-lifetime/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/session-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/sqlite-driver.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/sri/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/sri/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/state.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/storage/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/storage/src/local-cas.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/storage/src/local-stream.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/storage/src/local.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/storage/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/storage/src/meta-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/storage/src/s3.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/storage/src/util.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/storage-store.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/swig-filters/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/swig-filters/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/swig-filters/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/swig-resolver/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/swig-resolver/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/template-loaders/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/template-loaders/src/loaders/http.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/template-loaders/src/loaders/memory.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/template-loaders/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/url/README.md +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/url/index.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/url/routing.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/uuid/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/uuid/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/validator.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/watcher/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/watcher/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/ws-framing/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/ws-framing/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/ws-query/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/ws-query/src/main.js +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/ws-session/package.json +0 -0
- /package/framework/{v0.7.0 → v0.7.1}/lib/ws-session/src/main.js +0 -0
package/llms.txt
CHANGED
|
@@ -88,7 +88,7 @@ define(key, value) // Property definition helper
|
|
|
88
88
|
getEnvVar(key) // Read env variable
|
|
89
89
|
setEnvVar(key, val, protect) // Write env variable
|
|
90
90
|
onCompleteCall(emitter) // Wraps an EventEmitter .onComplete(cb) into a native Promise — lib/async/src/main.js, also available as global and via lib.async
|
|
91
|
-
run(cmdline[, opt][, cb]) // Run a local command (helpers/task.js; also gna.run) — returns an emitter with .onData(cb) / .onComplete(cb), chainable; opt.cwd (the process chdirs to it), opt.tmp. The positional cb and .onComplete(cb) throw a TypeError at the caller's line for a non-function since 0.6.28 (#B491) — a wrong argument used to be caught by the close handler and only logged, never delivered
|
|
91
|
+
run(cmdline[, opt][, cb]) // Run a local command (helpers/task.js; also gna.run) — returns an emitter with .onData(cb) / .onComplete(cb), chainable; opt.cwd (the process chdirs to it), opt.tmp (the base under which each run gets a PRIVATE `gina-run-*` directory, mode 0700, holding its `out.log` / `err.log` and removed with them at close — since 0.7.1, #B664/#B702: runs never share one fixed pair, another local user cannot pre-create the files in a shared /tmp, and the close handler always delivers the completion, a failed read as the error; `Shell::run` in lib/shell does the same under `GINA_TMPDIR`, falling back to `os.tmpdir()` when the global is undefined). The positional cb and .onComplete(cb) throw a TypeError at the caller's line for a non-function since 0.6.28 (#B491) — a wrong argument used to be caught by the close handler and only logged, never delivered
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
## Bare-module require — `require('lib/<name>')`
|
|
@@ -837,7 +837,7 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
837
837
|
|
|
838
838
|
54. **Bundle `config/*.json` shared → bundle overlay is key-level deep merge, not whole-file replace** — `core/config.js:1766` runs `merge(sharedMain, jsonFile, true)` AFTER the bundle file loads. Shared-only keys are preserved; bundle wins on conflicting keys. Applies to `connectors.json`, `env.json`, `settings.json`, and every other per-bundle config file that also has a `shared/config/<same-file>.json` sibling. Reading only the bundle file for config inspection is a silent half-picture — any CLI or tool that inspects per-bundle config must reproduce the overlay merge.
|
|
839
839
|
|
|
840
|
-
65. **Template engines — `lib/swig-resolver` + `lib/nunjucks-resolver` + per-request env mutation invariants** — process-cached resolver-based opt-in for both engines: `core/server.js:initSwigEngine` / `initNunjucksEngine` load via `lib.swigResolver.load()` / `lib.nunjucksResolver.load()` during bundle init; `controller.js this.render()` reads `local.options.conf.content.settings.render.engine` and dispatches to `controller.render-swig.js` (default) or `controller.render-nunjucks.js`. Both delegates receive the same `deps` object (`self`, `local`, `getData`, `hasViews`, `headersSent`, `setResources`, etc.); `process.gina._swig` / `process.gina._nunjucks` survive `refreshCoreDependencies()` evictions. Both: dev-mode mtime hot-swap on project's `package.json`, first-wins standalone-mode contract, `[<engine>-resolver]` warning logged on safety-gate fallback. **Swig**: framework fallback enabled (always-installable), version floor `DEFAULT_MIN = '2.0.0'` (bump in same commit as fork-API dependence — see global "Swig version sync" rule), three safety gates (package-name allowlist preventing CVE-2023-25345 re-entry, same-major rule, min-version floor), Phase 7 build copies upstream esbuild output to `dist/swig.min.js` (no longer Closure-compiles `bin/swig.js` — swig 2.0.0's `swig-core` lazy require breaks Closure's static analysis). **Output auto-escaping** (`settings.swig.autoescape`, boolean, default false; #B151): both `initSwigEngine` (boot) and `controller.js` per-render `setDefaults` resolve `autoescape` from `conf.content.settings.swig` (guarded to `{}`, then strict `=== true`) — ABSENT ⇒ false (unchanged 0.5.x behaviour; the old dead `conf.autoescape` read never fired); a non-boolean value refuses the boot (strictly-boolean security-toggle rule); `true` ⇒ swig HTML-escapes `{{ x }}` variable output; mirrors `settings.nunjucks.autoescape` (default true) — swig's default stays OFF until 0.8.0, which makes it `true`; since 0.7.0 (#B359 prep) a bundle that renders swig (settings engine, or a `.swig` templates section) and leaves the key unset gets ONE boot warning naming 0.8.0 (an explicit `true`/`false` silences it; a nunjucks-only bundle is not warned), `gina bundle:add` scaffolds `"swig": { "autoescape": true }`, the default delegate injects its asset placeholders as `{{ page.view.stylesheets | safe }}` / `{{ page.view.scripts | safe }}` (#B690 — bare, autoescape rendered every injected `<link>`/`<script>` as visible text; a layout placing them itself writes `| safe`, as for nunjucks), and `nl2br` escapes its input and is flagged `.safe` only when the bundle escapes (never `.safe` otherwise: a `{% autoescape true %}` region would then emit its input raw); the 0.8.0 flip needs `| safe` on every variable that carries HTML on purpose, `gina.csrfInput` first (unmarked it renders as text and every POST then fails CSRF); server-side only (no dist rebuild). **Nunjucks**: NEVER framework-bundled (resolver only looks in `<projectPath>/node_modules/nunjucks/`), `load()` throws `NUNJUCKS_NOT_INSTALLED` with no fallback (bundle startup fails loud, not mid-render). `new nunjucks.Environment(loader, ...)` cached per template root on `process.gina._nunjucksEnvs`; dev-mode `FileSystemLoader({ noCache: true })` for `.njk` template hot-edit. **Render-nunjucks.js implementation invariants**: per-request `addFilter → render` mutation of cached env is safe ONLY because `env.render()` is synchronous (Node's event loop serialises requests; an async-render port needs the per-request `process.gina._renderALS` context — a per-request env does NOT fix it: the filters read a process-global `*Filters.instance._options` singleton that a fresh env does not isolate (measured, #TPL1 Tier-2/#B25)); pre-render `setResources()` + post-render `injectAssets()` cooperate via exact-substring user-placement detection (#NJ2) — pre-only loses auto-injection, post-only double-injects on opt-in templates; both wrap bodies in try/catch (must NOT 500 the page); `injectAssets` runs BEFORE `injectInspectorScripts` (Inspector payload stays last `<script>` before `</body>`); `isWithoutLayout` filters via `Collection.find({isCommon:false},{isCommon:true,name:'gina'})` on a `JSON.clone(localTemplateConf)` to keep common-gina assets while dropping common-other. Test-author traps (both resolvers): mtime collision within same wall-clock ms requires monotonic counter; macOS `os.tmpdir()` symlink requires `fs.realpathSync` for `require.cache` key parity. **Per-extension engine dispatch (#M11)**: since #M11 the effective template EXTENSION overrides the engine setting per section — `.njk`→nunjucks, `.swig`→swig, `.html`→`render.engine`; precedence: setTemplate-override ext → `templates.json` section ext → default. NO filename sniffing (an `x.njk.html` must not mis-dispatch); auto-detect-on-`.njk`-presence was measured and DROPPED — explicit per-section ext covers its only value. **#B514 (0.6.30) — the DEFAULT swig delegates entered NO render context at all, and that is the wider half of the #B25 class.** #TPL1 Tier-2 wrapped the two `-async` delegates in `_renderALS.run()` and left `controller.render-swig.js` + `controller.render-v1.js` — the path every bundle takes unless it opts into `settings.template.swig.loader` — resolving filters through the raced `SwigFilters.instance._options` singleton. Both are themselves `async` and await between the per-request `SwigFilters({…})` stamp and the template invocation (render-swig's layout read is unconditional; render-v1 awaits a `Promise.all` of two reads before its compiles), so a concurrent request's stamp lands inside the window and the resumed render emits the OTHER request's negotiated culture, webroot and proxy classification into its own response. The in-tree note asserting "on a synchronous render that's safe" was FALSE when written — the default delegate went `async` in `64b19b7b2` (2026-03-06), three months BEFORE the #B25 fix — and that wrong premise is why the default path stayed unprotected. Both delegates now call `getRenderALS().enterWith(_renderCtx)` immediately after the factory call, handing the SAME object to both so the store and the singleton cannot disagree; `enterWith` rather than `.run()` because the body is one ~1,900-line async function whose every `return` would otherwise have to move inside a callback, and the store propagates to every continuation of the current async context, which is exactly the awaited span. Measured on a built `--env=prod` release — dev MASKS this, since `lib/index.js`'s `_require` re-requires the filter module per request and no singleton is shared, so a dev repro reads clean while production bleeds: 50 of 50 concurrent request pairs carried the other request's context pre-fix, 0 of 100 post-fix, and 0 of 3 across a REUSED keep-alive connection (2 confirmed reuse events — the one shape `enterWith` could have leaked through). Structurally the rate is last-writer-wins, so for N overlapping renders N−1 read the wrong context. **`render-nunjucks.js` is the one delegate genuinely free of the window** — its singleton write (`registerGinaFilters`) and its `env.render` sit in ONE synchronous run with no await between them, which is what the per-request-env sentence above actually rests on. ✅ **Sibling limb, CLOSED in the same arc — and it was NOT surgically closable:** `controller.js`'s per-render `swig.setDefaults({loader})` on the process singleton has the identical interleave shape for `{% include %}` / `{% extends %}` basepath resolution in MERGED-PROCESS mode (several bundles, one process). A per-call `loader` handed to `swig.compile()` CANNOT fix it — measured in swig-core: `self.parse` merges per-call options into a local that never escapes the function (`engine.js:477`), while `getParentsInternal` (`:321`) and `parseFile` (`:490`) read `self.options.loader` unconditionally, and the `{% include %}` codegen emits only `resolveFrom` (`backend.js:420`); the validators at swig.js:113 and runtime.js:58 accept a per-call loader and then nothing loads through it. The only working shape is a per-bundle `new swig.Swig({ loader })` instance, and that is what `controller.js` now builds — `getDefaultSwigEngine(swigMod, templateRoot, opts)`, keyed on the bundle template root, owner-guarded against a dev swig hot-swap exactly like `_swigEngines`, handed to the delegates as `deps.swig` and pointed at by `self.engine`. That last part is load-bearing: `router.js:944` hands `controller.engine` to the bundle's `controllers/setup.js`, so a bundle's OWN filters must land on the instance that renders it — `engine.install` gives every instance fresh tag/filter maps, so they would otherwise be silently dropped. **Live-verified against a baseline**: a scaffold whose `setup.js` registers a custom filter rendered `STAMP<x>` both before and after the change. The module-level `setDefaults` is deliberately KEPT — `core/server.js` still compiles inline routing/asset strings through the module with `swig.getOptions()` — so nothing renders a bundle template through the module any more and the stamp it races is inert. ⚠️ **#B535 (0.6.30, same tree) — that instance's registrations then had to be bridged BACK to the module, because "nothing renders a bundle template through it" is not the same as "nothing compiles through it".** `engine.install` gives every `new Swig()` FRESH filter/tag/extension maps — copied from the frontend's built-in library, NOT from the module's runtime registrations — and keeps them closure-private in BOTH directions (measured on the real fork, four arms with both controls firing: a filter registered on the module BEFORE or AFTER construction never reaches an instance, and an instance's never reaches the module). So while templates stopped rendering through the module, `core/server.js:3094`/`:3352` still compile inline routing/asset strings through it, and an application's own `require('@rhinostone/swig').compile()` — an entity rendering a message outside any request — still compiles through it; every one of those silently lost the filters a bundle's `controllers/setup.js` had registered, which on 0.6.29 reached them because `self.engine` WAS the module. Reported by a consumer as a `TypeError` thrown from INSIDE a filter (so the filter was found — the diagnostic that rules out a missing-filter reading): their own module-side registration was the only one left there, and its closure had never passed the router's export block. `bridgeRegistrationsToModule(engine, swigMod)`, called on the line after the mint in `getDefaultSwigEngine`, wraps the instance's `setFilter`/`setTag`/`setExtension` to register on the INSTANCE first (its own validation throws before the module is touched) and then on the module; idempotent per (instance, module) via an `_ginaModuleBridge` marker, and the owner-guard rebuild bridges each fresh instance. The instance keeps its OWN options — the loader above all — so the include/extends isolation this entry exists for is untouched; only the registration maps are shared again, which is the state every released version had. The REVERSE direction is deliberately NOT bridged: a module-registered filter still never reaches an instance, so registering anywhere other than through `this.engine` in `setup.js` stays unsupported — and that guidance is measured-correct, not merely conventional. DEVELOP-ONLY: the defect never shipped (the `0.6.30-alpha.1` tarball carries `getDefaultSwigEngine` = 0 and the pre-#B514 `self.engine = swig;`), so no released consumer tier was affected and no advisory is owed. ⚠️ **#B538 (0.6.30, same tree) — a SIBLING of #B535, not the same defect: `self.engine.getOptions()` threw `TypeError: … is not a function` on the accessor itself, before any compile.** `controller.js` installs `swig.getOptions` on the MODULE once per request (a closure over that request's `local._swigOptions`, a `JSON.clone` of `{ autoescape, cache, loader }`), and until #B514 `self.engine` WAS the module, so the documented call shape `self.engine.compile(tpl, self.engine.getOptions())(data)` resolved; the per-bundle instance never received the accessor, and the #B535 bridge carries the three registration SETTERS, not methods. It has ZERO framework call sites — the only internal reads are `core/server.js`'s two inline routing/asset compiles, both through the module — which is why the suite stayed green: the contract is consumer-facing, documented only by the comment at the assignment site. Diagnostic worth knowing: inside an async controller callback the TypeError surfaces as an UNHANDLED REJECTION and the request is never answered, so the CALLING bundle waits out its HTTP/2 stream timeout and reports a 503 with no template error and no access line on the answering side — it presents as a proxy/network fault. The accessor cannot be copied from the module (per-REQUEST closure onto a per-BUNDLE shared instance — the cross-request-bleed shape). `exposeOptionsOnEngine(engine, opts)`, called on the line after the #B535 bridge in the mint branch, snapshots `opts` ONCE at mint (measured: neither `new Swig(opts)` nor `setDefaults(opts)` mutates the object handed in, so the snapshot equals the pre-`setDefaults` clone every released version returned) and returns a fresh `JSON.clone` of it per call — a strict HARDENING over 0.6.29, which returned one shared object by reference. `JSON.clone` is gina's own deep copy (`utils/prototypes.json_clone.js`) and PRESERVES the loader's `resolve`/`load` functions; a `JSON.parse(JSON.stringify())` stand-in — the file's own commented-out previous implementation — strips them, and swig-core REFUSES a per-call `loader: {}` (`Invalid loader option`), so any probe built on the stand-in reads a broken loader that does not exist. Measured on the real fork: `compile(tpl)` and `compile(tpl, engine.getOptions())` are IDENTICAL (a differing `autoescape` as the firing control) — the round-trip is supported for compatibility, never required. Returning `instance.options` was rejected: swig-core's merged defaults are a wider surface than any release exposed. Pickup is a bundle RESTART (the registry on `process.gina` survives hot-reload's `require.cache` eviction, so the accessor lands only on instances minted after the restart). DEVELOP-ONLY like #B535 — the published `0.6.30-alpha.1` tarball carries the pre-#B514 shape and no tag contains `0e443f83b`; no consumer tier affected, no advisory owed. ⚠️ Note the fix is structural, NOT validated against its own trigger: there is no supported CLI path to a shared port (`port:set` rejects or evicts, never shares), and the only RUNTIME-MEASURED record on file reports siblings are not served in merged mode at all, so no reachable trigger was confirmed. ⚠️ **Two naming footguns for anyone who touches merged mode:** `isStandalone === true` means MERGED — *one process serves every bundle* — which is inverted from what the name suggests (`core/config.js:719` seeds it true, `:1210` flips it FALSE the moment any bundle's port differs from the starting bundle's, and `config.js:454` describes `!isStandalone` as the non-merged case). And it is ALSO true for an ordinary single-bundle project, so it is not a merged predicate on its own: `core/server.js` gates its merged-only warn on `bundles.length > 1` for exactly that reason. Merged mode is INFERRED from `~/.gina/ports.reverse.json` (the forward `ports.json` is declared but never read at boot), never configured — which is why no CLI verb reaches it. ⚠️ Instrument trap banked from the baseline: the probe extracting the custom filter's output used `[^<]*`, and the filter's own output contains `<`, so it read EMPTY — indistinguishable from « the bundle filter was dropped ». A negative reading about a filter must be validated against a known-positive before it is believed.
|
|
840
|
+
65. **Template engines — `lib/swig-resolver` + `lib/nunjucks-resolver` + per-request env mutation invariants** — process-cached resolver-based opt-in for both engines: `core/server.js:initSwigEngine` / `initNunjucksEngine` load via `lib.swigResolver.load()` / `lib.nunjucksResolver.load()` during bundle init; `controller.js this.render()` reads `local.options.conf.content.settings.render.engine` and dispatches to `controller.render-swig.js` (default) or `controller.render-nunjucks.js`. Both delegates receive the same `deps` object (`self`, `local`, `getData`, `hasViews`, `headersSent`, `setResources`, etc.); `process.gina._swig` / `process.gina._nunjucks` survive `refreshCoreDependencies()` evictions. Both: dev-mode mtime hot-swap on project's `package.json`, first-wins standalone-mode contract, `[<engine>-resolver]` warning logged on safety-gate fallback. **Swig**: framework fallback enabled (always-installable), version floor `DEFAULT_MIN = '2.0.0'` (bump in same commit as fork-API dependence — see global "Swig version sync" rule), three safety gates (package-name allowlist preventing CVE-2023-25345 re-entry, same-major rule, min-version floor), Phase 7 build copies upstream esbuild output to `dist/swig.min.js` (no longer Closure-compiles `bin/swig.js` — swig 2.0.0's `swig-core` lazy require breaks Closure's static analysis). **Output auto-escaping** (`settings.swig.autoescape`, boolean, default false; #B151): both `initSwigEngine` (boot) and `controller.js` per-render `setDefaults` resolve `autoescape` from `conf.content.settings.swig` (guarded to `{}`, then strict `=== true`; the per-render read goes through the REQUEST's own conf, `local.options.conf.content.settings.swig` — until 0.7.1 it went through the global no-argument `getConfig()`, whose caller walk throws under an npm install, and the surrounding `try` fell back to `false`: `autoescape: true` was silently OFF on every npm install from 0.5.25 to 0.7.0, while a checkout — every suite run — never showed it, #B695; the Tests workflow now boots a bundle from a copy of the commit placed under `node_modules/gina` to keep that install shape covered) — ABSENT ⇒ false (unchanged 0.5.x behaviour; the old dead `conf.autoescape` read never fired); a non-boolean value refuses the boot (strictly-boolean security-toggle rule); `true` ⇒ swig HTML-escapes `{{ x }}` variable output; mirrors `settings.nunjucks.autoescape` (default true) — swig's default stays OFF until 0.8.0, which makes it `true`; since 0.7.0 (#B359 prep) a bundle that renders swig (settings engine, or a `.swig` templates section) and leaves the key unset gets ONE boot warning naming 0.8.0 (an explicit `true`/`false` silences it; a nunjucks-only bundle is not warned), `gina bundle:add` scaffolds `"swig": { "autoescape": true }`, the default delegate injects its asset placeholders as `{{ page.view.stylesheets | safe }}` / `{{ page.view.scripts | safe }}` (#B690 — bare, autoescape rendered every injected `<link>`/`<script>` as visible text; a layout placing them itself writes `| safe`, as for nunjucks), and `nl2br` escapes its input and is flagged `.safe` only when the bundle escapes (never `.safe` otherwise: a `{% autoescape true %}` region would then emit its input raw); the 0.8.0 flip needs `| safe` on every variable that carries HTML on purpose, `gina.csrfInput` first (unmarked it renders as text and every POST then fails CSRF); server-side only (no dist rebuild). **Nunjucks**: NEVER framework-bundled (resolver only looks in `<projectPath>/node_modules/nunjucks/`), `load()` throws `NUNJUCKS_NOT_INSTALLED` with no fallback (bundle startup fails loud, not mid-render). `new nunjucks.Environment(loader, ...)` cached per template root on `process.gina._nunjucksEnvs`; dev-mode `FileSystemLoader({ noCache: true })` for `.njk` template hot-edit. **Render-nunjucks.js implementation invariants**: per-request `addFilter → render` mutation of cached env is safe ONLY because `env.render()` is synchronous (Node's event loop serialises requests; an async-render port needs the per-request `process.gina._renderALS` context — a per-request env does NOT fix it: the filters read a process-global `*Filters.instance._options` singleton that a fresh env does not isolate (measured, #TPL1 Tier-2/#B25)); pre-render `setResources()` + post-render `injectAssets()` cooperate via exact-substring user-placement detection (#NJ2) — pre-only loses auto-injection, post-only double-injects on opt-in templates; both wrap bodies in try/catch (must NOT 500 the page); `injectAssets` runs BEFORE `injectInspectorScripts` (Inspector payload stays last `<script>` before `</body>`); `isWithoutLayout` filters via `Collection.find({isCommon:false},{isCommon:true,name:'gina'})` on a `JSON.clone(localTemplateConf)` to keep common-gina assets while dropping common-other. Test-author traps (both resolvers): mtime collision within same wall-clock ms requires monotonic counter; macOS `os.tmpdir()` symlink requires `fs.realpathSync` for `require.cache` key parity. **Per-extension engine dispatch (#M11)**: since #M11 the effective template EXTENSION overrides the engine setting per section — `.njk`→nunjucks, `.swig`→swig, `.html`→`render.engine`; precedence: setTemplate-override ext → `templates.json` section ext → default. NO filename sniffing (an `x.njk.html` must not mis-dispatch); auto-detect-on-`.njk`-presence was measured and DROPPED — explicit per-section ext covers its only value. **#B514 (0.6.30) — the DEFAULT swig delegates entered NO render context at all, and that is the wider half of the #B25 class.** #TPL1 Tier-2 wrapped the two `-async` delegates in `_renderALS.run()` and left `controller.render-swig.js` + `controller.render-v1.js` — the path every bundle takes unless it opts into `settings.template.swig.loader` — resolving filters through the raced `SwigFilters.instance._options` singleton. Both are themselves `async` and await between the per-request `SwigFilters({…})` stamp and the template invocation (render-swig's layout read is unconditional; render-v1 awaits a `Promise.all` of two reads before its compiles), so a concurrent request's stamp lands inside the window and the resumed render emits the OTHER request's negotiated culture, webroot and proxy classification into its own response. The in-tree note asserting "on a synchronous render that's safe" was FALSE when written — the default delegate went `async` in `64b19b7b2` (2026-03-06), three months BEFORE the #B25 fix — and that wrong premise is why the default path stayed unprotected. Both delegates now call `getRenderALS().enterWith(_renderCtx)` immediately after the factory call, handing the SAME object to both so the store and the singleton cannot disagree; `enterWith` rather than `.run()` because the body is one ~1,900-line async function whose every `return` would otherwise have to move inside a callback, and the store propagates to every continuation of the current async context, which is exactly the awaited span. Measured on a built `--env=prod` release — dev MASKS this, since `lib/index.js`'s `_require` re-requires the filter module per request and no singleton is shared, so a dev repro reads clean while production bleeds: 50 of 50 concurrent request pairs carried the other request's context pre-fix, 0 of 100 post-fix, and 0 of 3 across a REUSED keep-alive connection (2 confirmed reuse events — the one shape `enterWith` could have leaked through). Structurally the rate is last-writer-wins, so for N overlapping renders N−1 read the wrong context. **`render-nunjucks.js` is the one delegate genuinely free of the window** — its singleton write (`registerGinaFilters`) and its `env.render` sit in ONE synchronous run with no await between them, which is what the per-request-env sentence above actually rests on. ✅ **Sibling limb, CLOSED in the same arc — and it was NOT surgically closable:** `controller.js`'s per-render `swig.setDefaults({loader})` on the process singleton has the identical interleave shape for `{% include %}` / `{% extends %}` basepath resolution in MERGED-PROCESS mode (several bundles, one process). A per-call `loader` handed to `swig.compile()` CANNOT fix it — measured in swig-core: `self.parse` merges per-call options into a local that never escapes the function (`engine.js:477`), while `getParentsInternal` (`:321`) and `parseFile` (`:490`) read `self.options.loader` unconditionally, and the `{% include %}` codegen emits only `resolveFrom` (`backend.js:420`); the validators at swig.js:113 and runtime.js:58 accept a per-call loader and then nothing loads through it. The only working shape is a per-bundle `new swig.Swig({ loader })` instance, and that is what `controller.js` now builds — `getDefaultSwigEngine(swigMod, templateRoot, opts)`, keyed on the bundle template root, owner-guarded against a dev swig hot-swap exactly like `_swigEngines`, handed to the delegates as `deps.swig` and pointed at by `self.engine`. That last part is load-bearing: `router.js:944` hands `controller.engine` to the bundle's `controllers/setup.js`, so a bundle's OWN filters must land on the instance that renders it — `engine.install` gives every instance fresh tag/filter maps, so they would otherwise be silently dropped. **Live-verified against a baseline**: a scaffold whose `setup.js` registers a custom filter rendered `STAMP<x>` both before and after the change. The module-level `setDefaults` is deliberately KEPT — `core/server.js` still compiles inline routing/asset strings through the module with `swig.getOptions()` — so nothing renders a bundle template through the module any more and the stamp it races is inert. ⚠️ **#B535 (0.6.30, same tree) — that instance's registrations then had to be bridged BACK to the module, because "nothing renders a bundle template through it" is not the same as "nothing compiles through it".** `engine.install` gives every `new Swig()` FRESH filter/tag/extension maps — copied from the frontend's built-in library, NOT from the module's runtime registrations — and keeps them closure-private in BOTH directions (measured on the real fork, four arms with both controls firing: a filter registered on the module BEFORE or AFTER construction never reaches an instance, and an instance's never reaches the module). So while templates stopped rendering through the module, `core/server.js:3094`/`:3352` still compile inline routing/asset strings through it, and an application's own `require('@rhinostone/swig').compile()` — an entity rendering a message outside any request — still compiles through it; every one of those silently lost the filters a bundle's `controllers/setup.js` had registered, which on 0.6.29 reached them because `self.engine` WAS the module. Reported by a consumer as a `TypeError` thrown from INSIDE a filter (so the filter was found — the diagnostic that rules out a missing-filter reading): their own module-side registration was the only one left there, and its closure had never passed the router's export block. `bridgeRegistrationsToModule(engine, swigMod)`, called on the line after the mint in `getDefaultSwigEngine`, wraps the instance's `setFilter`/`setTag`/`setExtension` to register on the INSTANCE first (its own validation throws before the module is touched) and then on the module; idempotent per (instance, module) via an `_ginaModuleBridge` marker, and the owner-guard rebuild bridges each fresh instance. The instance keeps its OWN options — the loader above all — so the include/extends isolation this entry exists for is untouched; only the registration maps are shared again, which is the state every released version had. The REVERSE direction is deliberately NOT bridged: a module-registered filter still never reaches an instance, so registering anywhere other than through `this.engine` in `setup.js` stays unsupported — and that guidance is measured-correct, not merely conventional. DEVELOP-ONLY: the defect never shipped (the `0.6.30-alpha.1` tarball carries `getDefaultSwigEngine` = 0 and the pre-#B514 `self.engine = swig;`), so no released consumer tier was affected and no advisory is owed. ⚠️ **#B538 (0.6.30, same tree) — a SIBLING of #B535, not the same defect: `self.engine.getOptions()` threw `TypeError: … is not a function` on the accessor itself, before any compile.** `controller.js` installs `swig.getOptions` on the MODULE once per request (a closure over that request's `local._swigOptions`, a `JSON.clone` of `{ autoescape, cache, loader }`), and until #B514 `self.engine` WAS the module, so the documented call shape `self.engine.compile(tpl, self.engine.getOptions())(data)` resolved; the per-bundle instance never received the accessor, and the #B535 bridge carries the three registration SETTERS, not methods. It has ZERO framework call sites — the only internal reads are `core/server.js`'s two inline routing/asset compiles, both through the module — which is why the suite stayed green: the contract is consumer-facing, documented only by the comment at the assignment site. Diagnostic worth knowing: inside an async controller callback the TypeError surfaces as an UNHANDLED REJECTION and the request is never answered, so the CALLING bundle waits out its HTTP/2 stream timeout and reports a 503 with no template error and no access line on the answering side — it presents as a proxy/network fault. The accessor cannot be copied from the module (per-REQUEST closure onto a per-BUNDLE shared instance — the cross-request-bleed shape). `exposeOptionsOnEngine(engine, opts)`, called on the line after the #B535 bridge in the mint branch, snapshots `opts` ONCE at mint (measured: neither `new Swig(opts)` nor `setDefaults(opts)` mutates the object handed in, so the snapshot equals the pre-`setDefaults` clone every released version returned) and returns a fresh `JSON.clone` of it per call — a strict HARDENING over 0.6.29, which returned one shared object by reference. `JSON.clone` is gina's own deep copy (`utils/prototypes.json_clone.js`) and PRESERVES the loader's `resolve`/`load` functions; a `JSON.parse(JSON.stringify())` stand-in — the file's own commented-out previous implementation — strips them, and swig-core REFUSES a per-call `loader: {}` (`Invalid loader option`), so any probe built on the stand-in reads a broken loader that does not exist. Measured on the real fork: `compile(tpl)` and `compile(tpl, engine.getOptions())` are IDENTICAL (a differing `autoescape` as the firing control) — the round-trip is supported for compatibility, never required. Returning `instance.options` was rejected: swig-core's merged defaults are a wider surface than any release exposed. Pickup is a bundle RESTART (the registry on `process.gina` survives hot-reload's `require.cache` eviction, so the accessor lands only on instances minted after the restart). DEVELOP-ONLY like #B535 — the published `0.6.30-alpha.1` tarball carries the pre-#B514 shape and no tag contains `0e443f83b`; no consumer tier affected, no advisory owed. ⚠️ Note the fix is structural, NOT validated against its own trigger: there is no supported CLI path to a shared port (`port:set` rejects or evicts, never shares), and the only RUNTIME-MEASURED record on file reports siblings are not served in merged mode at all, so no reachable trigger was confirmed. ⚠️ **Two naming footguns for anyone who touches merged mode:** `isStandalone === true` means MERGED — *one process serves every bundle* — which is inverted from what the name suggests (`core/config.js:719` seeds it true, `:1210` flips it FALSE the moment any bundle's port differs from the starting bundle's, and `config.js:454` describes `!isStandalone` as the non-merged case). And it is ALSO true for an ordinary single-bundle project, so it is not a merged predicate on its own: `core/server.js` gates its merged-only warn on `bundles.length > 1` for exactly that reason. Merged mode is INFERRED from `~/.gina/ports.reverse.json` (the forward `ports.json` is declared but never read at boot), never configured — which is why no CLI verb reaches it. ⚠️ Instrument trap banked from the baseline: the probe extracting the custom filter's output used `[^<]*`, and the filter's own output contains `<`, so it read EMPTY — indistinguishable from « the bundle filter was dropped ». A negative reading about a filter must be validated against a known-positive before it is believed.
|
|
841
841
|
|
|
842
842
|
72. **Supply-chain visibility — tarball enumeration, peerDependencies, security-commit bundle coupling.** Enumerate a published tarball via `npm view <pkg>@<ver> dist.tarball` + `curl` + `tar tzf` (NOT `npm pack --dry-run` — it triggers the prepare script's "Prerelease update" commit). **`peerDependencies` aggregate into the dep graph regardless of `optional: true`** — Socket / Dependabot / `npm audit` all read peerDeps unconditionally; the fix pattern is removing them entirely in favour of a lib-local version registry (gina's `lib/connector-registry/`). **Security-tagged source commits MUST rebuild the browser bundle in the same commit** (pre-commit hook + the bundle-freshness CI workflow enforce it). The vendored-deps era is CLOSED: `core/deps/` was deleted 2026-07-18 (`4c173422` require-swap + `4a8eb1e6` deletion — busboy became the `@rhinostone/busboy` npm fork; the OSV workflow now queries the fork's tracked base versions (`busboy@1.6.0`, `streamsearch@1.1.0`) explicitly via a `TRACKED_FORKS` table in the vendored-CVE scan script, which still walks any future vendored sub-manifest). If a dep is ever vendored again: its sub-`package.json` dependency edges are BY DESIGN and load-bearing for CVE visibility — never strip them (the `4a29ca0c` strip was reverted in `e5d5d0a2` for exactly this).
|
|
843
843
|
|
|
@@ -848,7 +848,7 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
848
848
|
98. **Secrets — `${secret:KEY}` config placeholders + the `secrets:scan`/`secrets:check` introspection CLI (absorbs former #135).** `lib/secrets` substitutes `${secret:KEY}` at config-load (`core/config.js::loadBundleConfig`, post-merge per bundle), resolving each key from the FRAMEWORK environment first (`getEnvVar`) and falling back to `process.env[KEY]`. **That two-tier read is load-bearing, not defensive (0.6.3, #B156):** the CLI MOVES every `GINA_*`/`USER_*`/`VENDOR_*` key OUT of `process.env` into `process.gina` (`filterArgs`, `utils/helper.js`), so a `${secret:GINA_*}` placeholder was previously unresolvable in ANY CLI-loaded config — `mcp.json` (the published example), the connector commands, `audit:verify` — and failed closed with a refusing boot. Non-`GINA_` keys are untouched by the sweep and resolve as before. The same idiom now backs the CSRF `GINA_CSRF_SECRET` tier and the MCP transport's `GINA_BIND_HOST`/`GINA_DIR` reads (#B157). ANCHORED: the regex matches only when the ENTIRE string value is the placeholder — mixed strings (`"prefix-${secret:K}-suffix"`) pass through unchanged; non-string scalars untouched; nested objects/arrays descend. **#B583 (0.6.33) — a MALFORMED whole-value reference is REFUSED, never passed through:** a value that is nothing but a `${secret:…}` token whose key breaks `^[A-Z_][A-Z0-9_]*$` (lowercase, dotted, empty, leading digit), or a VALID token padded with surrounding whitespace, throws `Secret reference malformed at `<path>`: … KEY must match ^[A-Z_][A-Z0-9_]*$ …` at config load — the message names the config PATH and the grammar, never the offending text, which rides a non-enumerable `_ginaSecretRef` for the config-load DEBUG line (the #B42 policy; `_ginaSecretKey` is absent on it). Previously such a value passed through UNCHANGED (only `SECRET_RE` was consulted, and a non-match meant "mixed content"), so the literal placeholder text reached its consumer as a credential and a database driver authenticated with it, silently — the failure surfaced two layers deep as an authentication error. `MALFORMED_RE` (exported beside `SECRET_RE`, BUILT from it through a negative lookahead so the key grammar has one home; use `.test()`, its only capture is inert) is the class; mixed content (`https://${secret:H}/v1`, `${secret:A}-${secret:B}`) keeps the documented passthrough and a differently-spelled namespace (`${SECRET:X}`) is not the token. `getMalformedReferences(config) → [{path, ref}]` is the read-only, non-throwing sibling of `getRequiredKeys` (which never lists a malformed key — it names no usable key), both enumerated by ONE walker; the shared source walk returns `malformed: [{file, path, ref}]` per bundle (`sourcesFactory(getRequiredKeys, getMalformedReferences)`, shared entries first, de-duplicated by file + path, scope-attributed like a key); `secrets:check` prints `! MALFORMED reference at `<path>` in <file> — the runtime REFUSES to boot on this: …` naming the text too (the CLI already prints key names; the boot log is the surface that hides them), counts them in the summary and sets `anyError` (exit 1 — a gate must not pass what boot refuses, the #B408 class), `secrets:scan` lists them under `Malformed references (N)` (exit stays 0); `connector:test`/`infer`/`models` relay the path-naming message instead of `secret resolution failed for `<unknown>``. First-error-wins in walk order in `resolve()` (like the declaration guards); the CLI enumerates them all. Compatibility: a config that booted with such a literal now refuses — always a mistake, migration note under 0.6.32 → 0.6.33. Pickup: bundle restart, no re-bake (`lib/secrets` is not in the browser bundle). **Fail-closed:** an unset/empty env var throws a GENERIC `Secret resolution failed` — the key name rides only a non-enumerable `_ginaSecretKey`, surfaced at debug level by the config-load catch naming key + `<bundle>/<env>:<scope>` (#B42), so consumer-visible surfaces never carry key names. Secret rotation needs a process restart (the resolver re-runs on `config.refresh()`, but the env is inherited from container init). Pluggable backend interface (`{resolve(key)}` — env-only by default, env-over-file once `secrets.file` is declared, env-over-exec once `secrets.exec` is declared; the two declared tiers are mutually exclusive by validation). **The two tiers are read INDEPENDENTLY, never joined by `||` (0.6.9, #B270):** `filterArgs` stores swept values as REAL booleans, so a truthy boolean used to satisfy the short-circuit and `process.env` was never consulted — the file tier then beat a SET environment variable, inverting the precedence the file backend exists to enforce. Only a non-empty STRING from the framework tier wins; a boolean, number, `undefined` or `''` falls through. `getResolvedPaths()` gives the dotted paths substituted in a config, but it is **not** a general redaction substrate (0.6.9, #B274): the paths address the config object and the lookup is keyed on its IDENTITY, so a consumer must hold the very object `resolve()` mutated — which rules out the Inspector, whose payload is a structural clone of the controller's VIEW data (`{gina, user}`), a different object in a different coordinate space. A surface holding VALUES rather than the config needs a value-based pass; `core/connectors/param-redact.js` exists for exactly that reason. **`secrets.file` shape guards (0.6.9):** `[]` still disables the tier like `null` but now WARNS rather than doing it silently (#B271 — an operator emptying the array to drop one layer drops the whole tier; boot is not refused, since an empty list genuinely means "no files"); a whitespace-only entry is REFUSED (`minLength: 1` counts a space, so `[" "]` used to build a tier that could never resolve anything); and a path containing an empty segment (`//`) is refused (#B272 — such a path does not name the file it appears to, since POSIX reads `<a>//<b>` as `<a>/<b>`; the DANGEROUS cause is a `${…}` token that resolved to EMPTY, leaving no token for the unresolved-token guard and silently reading the file one directory UP — `${homedir}/${scope}/secrets.env` with an empty scope — while the BENIGN cause is a token with a trailing slash such as an operator's `GINA_HOMEDIR=/opt/gina/`, which nothing in the path chain normalises. The two are indistinguishable once assembled, so boot refuses on both and the error names both causes rather than asserting one it cannot know; the benign case is a one-character fix). The schema gains `minItems: 1` as an editor-facing hint — no runtime validator loads `schema/settings.json`, so `lib/secrets` is the enforcement. **#B408 (0.6.14) — those declaration guards are ONE shared implementation for the runtime AND the gate:** `selectBackend` and `secrets:check` both consume `secrets.validateFilePaths` (`lib/secrets/src/declaration.js` — pure and require-free, first-error-wins in entry order, with the `${secret:…}` verdict outranking the generic unresolved-token one because the token regex matches a placeholder too), because the checker's hand-kept mirror had drifted twice in the LAX direction: the #B271 trim and #B272 `//` guards never reached it, so `settings.secrets.file: [" "]` and a token-collapsed `//` path passed `gina secrets:check` GREEN while boot REFUSED — the exact checker-vs-runtime disagreement the command exists to prevent, and the second #B263-class drift after the source walk. The gate now also validates BEFORE reading any layer, as boot does (an invalid declaration reports with NO layers read), and keeps its CLI-only `--scope`/`--env` hint by APPENDING to the shared message on the `file-unresolved-token` error code, never by forking the text. **FILE SYNTAX — one parser (`lib/secrets/src/env-file.js::parseEnv`) serves BOTH readers (`secrets:check` + the file backend), so the gate cannot green-light a file the runtime reads differently.** It targets what a POSIX shell `source`s, since the documented entrypoint (`set -a; . secrets.env; set +a`) is a THIRD reader the module cannot unify by construction. **#B269 (0.6.4) — a trailing `#` comment is now stripped** (previously swallowed into the value): the strip fires only when the `#` is preceded by whitespace AND outside quotes, because `abc#def` is a literal hash to a shell and `"abc # def"` is a quoted one — so quoting is a COMPLETE escape hatch, and the strip runs on the RAW pre-trim value (trimming first makes `KEY= # c` and `KEY=#c` indistinguishable while the shell reads them as `''` vs `'#c'`). Unquoting applies AFTER the strip, so `KEY="abc" # note` is `abc`, not `"abc"` — previously the comment defeated the `/^".*"$/` test and the quotes became part of the credential. Consequence to expect: `KEY= # comment` is now EMPTY, and empty counts as unset in both readers (`typeof v === 'string' && v !== ''`), so it fails closed and `secrets:check` reports UNSET where it previously reported a FALSE-GREEN SET. Two divergences REMAIN by decision — `KEY = value` (accepted+trimmed here, a shell errors) and `KEY="a"b"` (`a"b` here, `ab` in a shell) — so do NOT restate the parser as "whatever a POSIX shell does". **`getRequiredKeys(config)`** is the read-only, non-throwing sibling backing the OFFLINE CLI: `gina secrets:scan [<bundle>] [@project]` (which keys each bundle requires, grouped by file — sources derived from the manifest's `src`, never a guessed path) + `gina secrets:check` (marks SET/UNSET, exits non-zero — the CI gate), with `--scope=<s>` overlaying `config_<s>/` dirs READ-ONLY (the runtime loader stays scope-agnostic about config DIRS) and `--env-file` standing in for the ENV TIER. **The config-dir walk behind BOTH commands is ONE implementation in `lib/secrets` (`getProjectRequiredKeys(projectPath, {scope, bundle})` → `{bundles: [{bundle, byKey}]}`, provenance labels always on; the `loadManifest`/`readJsonSafe`/`resolveBundleSrc` leaves ride along for the handlers' own single-file reads):** the two handlers previously each carried a byte-near-identical copy of the walk — copies of exactly the kind #B263's drift came from — and the shared walk is also the enumeration a boot-time prefetch consumer would need. Non-throwing family: `null` for a bad `projectPath` or a missing manifest with no bundle filter; an explicit `bundle` filter is honoured VERBATIM (walks even a manifest-less project, src falling back to the bundle name — the historical CLI shape). It resolves `_()`/`requireJSON` at CALL time only, so module load needs no framework globals; and a config file that exists but does not PARSE follows requireJSON's own loud contract (emerg + exit while `console.emerg` exists) rather than the null-skip the old in-handler helper JSDoc claimed. **#B263 (0.6.4) — `check` now reads BOTH runtime tiers, in order:** env, then the bundle's declared `secrets.file` chain resolved through the SAME global `whisper` + the shared `parseEnvFile`, so it can no longer report UNSET (and exit non-zero) for a key the bundle would have booted with — that false RED, not a false green, was the dominant real-world defect, since adding a source can only turn UNSET→SET. Parity is BY CONSTRUCTION: `homedir` comes from the project's own `env.json` (`<bundle>.<env>`) else the template's `~/.<projectName>`, NEVER `projects.json`'s `homedir` (a CLI-level field `config.js` never reads). A reps key is seeded only from a NON-EMPTY string — an absent key leaves its token verbatim and is caught loudly, but an EMPTY one substitutes SILENTLY (measured: `scope:''` → `/h//f.env`, which collapses to the base path on POSIX with no guard firing; whisper's pass 2 `dict[k]||s` leaves it, pass 3 `!==undefined` rescues it). `${scope}`/`${env}` are LAUNCH-time values the CLI cannot read (runtime prefers `NODE_SCOPE`/`NODE_ENV`), so the report NAMES the values it assumed, `--env` was added to pick the env block, and an unresolvable path is reported + its tier skipped rather than statted — under-reading can only make the gate stricter than the runtime, never laxer. **#B266 (`74e52cda`) — the reps dict must carry every token a REAL chain uses, not the ones the examples use.** The first consumer config (`${homedir}/v${projectVersionMajor}/credentials/[${scope}/]secrets.env`) resolved at runtime but NOT in `check`, re-opening the #B263 false-RED through three holes: (a) **version tokens absent** — the runtime does not list them in the bundle-config dict either, it gets them because `core/template/conf/env.json` declares `projectVersion`/`projectVersionMajor` as per-bundle SCALARS which the `:2356` harvest then copies in, so **reading only the `:2296` dict literal shows no version tokens and looks complete**; now seeded from the manifest; (b) **`${scope}` had no default** — now falls back to the project default via a SEPARATE `self.scopeAssumed` slot, because defaulting `self.scopeName` would start applying the `config_<scope>/` overlay to every bare invocation (two axes, one name); (c) **`~/.undefined`** — `buildReps` used `self.projectName`, undefined on the all-projects branch, yielding a real stat-able wrong path; now derived from the project PATH. Declaration resolution is pinned (`5a80b1a1`): bundle `secrets.file` REPLACES shared's (arrays never concatenate), `secrets:{}` INHERITS, `secrets:{file:null}` is the opt-out, and `--env-file` stays TOP tier (it stands in for the environment; demoting it below the files would model something the runtime never does). Each key's report names the tier that satisfied it. ⚠️ The other `secrets.resolve()` callers pass NO backend and stay env-only (`bundle:mcp-start`, `connector:infer`/`test`/`models` — #B264). Design of record: the framework RESOLVES secrets, the deployment layer STORES them. Since 0.6.4 it may additionally READ declared plaintext files as the LOWEST-precedence tier — opt-in `settings.secrets.file` in a bundle's `settings.json`, one path or an array written with the usual config tokens (`["${homedir}/secrets.env", "${homedir}/${scope}/secrets.env"]`), later entries winning. Files layer UNDER the environment (`getEnvVar` → `process.env` → files → throw), which INVERTS the usual `.env` intuition and is deliberate: every production delivery path (K8s `secretRef`, ECS task secrets, `sops exec-env`, a CI export) arrives via the environment, so a file that won could let a stale plaintext copy shadow the real credential. ⚠️ Precisely, it is a NON-EMPTY env value that wins (#B268, `90518bec`): a SET-but-EMPTY variable counts as ABSENT and the file fills it — deliberate and kept, because `environment: ["X=${X}"]` with the outer var unset puts an empty-but-SET var in the container (measured) and refusing would break working setups — but since a failed `export X="$(fetch …)"` produces that same shape, the fall-through WARNS, naming the key and never the value, at `warn` so the default `info` hierarchy keeps it. Undeclared ⇒ byte-identical to env-only; a declared-but-ABSENT file contributes nothing, but one that EXISTS and cannot be READ is FATAL (#B267, `90518bec`) — collapsing both to `null` let a `[base, per-scope]` chain whose per-scope file lost read permission boot silently on the SHARED credential (measured: `production_password` → `shared_dev_password`), announced only by a debug line MISLABELLED `ABSENT` which `info` suppresses; `env-file` now also exports `readEnvFile` (reports the errno — ENOENT vs EACCES/EISDIR; a dangling symlink is ENOENT, correctly absent), the backend refuses on anything non-ENOENT naming path + code, and `secrets:check` reports `UNREADABLE` so the CI gate and the runtime cannot disagree about a file neither can open; resolution stays fail-closed; paths carrying an unresolved `${…}` or a `${secret:…}` are rejected at boot. Selection reads `content.settings`, NOT `settings` — historically the only copy with tokens resolved, because `config.js` bound `.settings` BEFORE the token-substitution pass and `.content` after it, and substitution returns a NEW object rather than rewriting the original. **Since #B257 (0.6.7) the `.settings` alias is re-pointed at the post-substitution copy, so for a config built by `loadBundleConfig` the two are now the SAME object** and either read would serve; `content.settings` stays the canonical one because `selectBackend` accepts any config-shaped object, including ones assembled by other paths. ⚠️ The old rationale was never accurate AS STATED (#B273) and must not be restated: an EARLIER substitution pass already resolved `${homedir}` / `${scope}` in both copies, so what actually diverged was only the tokens pass 2 is the FIRST to know — `${bundlePath}`, `${libPath}`, `${publicPath}`, `${handlersPath}`, `${mountPath}`, `${gina}`, `${project}`, `${root}`, `${source}`, `${<name>Port}`, `${templates}`/`${html}`/`${theme}` and the `:2356` scalar harvest. `${secret:…}` was never affected in either copy, since `secrets.resolve()` walks the config IN PLACE and so reaches both subtrees. Consumer-visible consequence of the fix: a `getConfig().settings.<x>` read that used to hand back a literal `'${libPath}/…'` now returns the resolved path.The FILE tier does NOT decrypt — pointed at ciphertext it yields ciphertext, silently. **Since 0.6.14, `settings.secrets.exec` is the sanctioned in-process bridge for SOPS/Vault/K8s (#SECRETS3):** ONE operator-declared command — argv array, NO shell — run once per bundle per config-load cycle at boot only, whose stdout must be a single flat JSON object of string values (`sops decrypt --output-type json` on a flat secrets file emits it directly, measured end-to-end; `vault`/`kubectl` need a 2-3 line wrapper script — see the guide's recipes), layered UNDER the environment exactly like the file tier and MUTUALLY EXCLUSIVE with it: a bundle inheriting a shared `file` chain escapes with `"file": null` beside its own `exec` block (the refusal message names it), `"exec": null` symmetrically; unknown `secrets` keys and non-object blocks now REFUSE the boot instead of silently degrading to env-only (runtime parity with the schema's `additionalProperties: false`). The boot STAYS synchronous (#P33 intact): the fetch is `spawnSync` bounded by `timeout` (default 10000ms, 2× the house probe class) and killed with **SIGKILL** on expiry — measured-mandatory, not caution: the default SIGTERM leaves `spawnSync` BLOCKED past its timeout against a signal-ignoring child and then reports `{error: ETIMEDOUT, status: 0}`, which is also why the result check reads `.error` BEFORE `.status` (a status-first read calls that shape a success). So a wedged KMS becomes a FAILED boot, never a hung one — every fetch failure (timeout, missing binary, non-zero exit with stderr tail quoted, malformed output) is the per-bundle boot refusal through `config.js`'s existing catch. stdout is the secrets payload and NEVER appears in any error, log or report (a non-string value's key rides the debug channel only); auth travels via environment variables, never argv (`ps`-visible), and the child inherits `process.env` (the #B156 sweep means `GINA_*` / `VENDOR_*` / `USER_*` keys are invisible to it in CLI/daemon processes). `secrets:check` mirrors the tier by RUNNING the declared command through the runtime's own `fetchExecMap` (same timeout, same SIGKILL bound, same output contract — a validate-only mirror would false-RED every exec-supplied key, the #B263 shape), and **#B409 (0.6.14)**: declaration/fetch errors now fail the gate's EXIT CODE too — both exit sites widened to `anyUnset || anyError`; previously a boot-refusing declaration exited 0 whenever the environment happened to carry the keys. **The entrypoint decrypt remains the BETTER pattern wherever the entrypoint can be controlled** (`sops exec-env`, K8s `envFrom` secretRef, Vault agent-injector — values land in the environment, which every tier already defers to): `exec` supports that pattern for environments that cannot, it does not replace it. (The scope-agnostic note above is about `config_<s>/` DIRS, which the runtime still does not overlay; a secrets FILE path may be scope-varying via `${scope}`.) Consumers: `Csrf` (`settings.csrf.secret`), `bundle:mcp-start` (re-resolves `mcp.json` post-parse), `auth.machine` caller keys.
|
|
849
849
|
|
|
850
850
|
125. **CLI handler authoring & operations — consolidated** (replaces individual entries #17, #23, #24, #55, #56, #58, #59, #60, #62, #63, #102, #112; plus #161, #166, #167, #181, #182, #183, #185, #199, #201, #202, #203 folded 2026-07-05):
|
|
851
|
-
- **CLI stubs** — `gina --status` / `gina -t` appear in help.txt or docs but have no handler (not in `aliases.json`); tracked in `ROADMAP.md § CLI`; never suggest to users without checking the handler file first. `bundle:status` / `project:status` / `minion:list` / `minion:kill` / `protocol:remove` / `bundle:copy` (+ `cp` alias) / `bundle:rename` shipped 0.4.1-alpha.2; `project:move` / `project:backup` / `project:restore` / `framework:update` / `framework:man` (+ `project:man` / `bundle:man` / `service:man`) — the **CLI Tier 3** finals — shipped 0.5.x (full coverage in the two CLI Tier 3 sub-bullets below). Both minion commands are run-dir-driven process-truth (the "minion" abstraction is half-wired — nothing sets `process.isMinion` or writes `*minion*.pid`, so a minion == any running bundle child-process): `minion:list` lists every live `<bundle>@<project>.pid` grouped by project via `lib.cmdStatusFormat`; `minion:kill @<project>` reaps them (hybrid kill-set = run-dir pidfiles + a `ps -ef
|
|
851
|
+
- **CLI stubs** — `gina --status` / `gina -t` appear in help.txt or docs but have no handler (not in `aliases.json`); tracked in `ROADMAP.md § CLI`; never suggest to users without checking the handler file first. `bundle:status` / `project:status` / `minion:list` / `minion:kill` / `protocol:remove` / `bundle:copy` (+ `cp` alias) / `bundle:rename` shipped 0.4.1-alpha.2; `project:move` / `project:backup` / `project:restore` / `framework:update` / `framework:man` (+ `project:man` / `bundle:man` / `service:man`) — the **CLI Tier 3** finals — shipped 0.5.x (full coverage in the two CLI Tier 3 sub-bullets below). Both minion commands are run-dir-driven process-truth (the "minion" abstraction is half-wired — nothing sets `process.isMinion` or writes `*minion*.pid`, so a minion == any running bundle child-process): `minion:list` lists every live `<bundle>@<project>.pid` grouped by project via `lib.cmdStatusFormat`; `minion:kill @<project>` reaps them (hybrid kill-set = run-dir pidfiles + a `ps -ef` sweep for pidfile-less orphans bundle:stop misses, run by `execFileSync` without a shell since #B665 and matching any `gina: <bundle>@<project>` title of the project in JS, the project name escaped and ending at whitespace or the end of the line), SIGTERM→grace→SIGKILL escalation, `--dry-run` preview, unlinks stale/killed pidfiles, never touches mount symlinks or its own PID. `protocol:remove <bundle> @<project>` reverts a bundle to the project default protocol by deleting ONLY its `server.protocol/scheme/allowHTTP1` override from the bundle's `settings.json` (config.js `:1014/:1020` auto-defaults an absent protocol to `def_protocol`/`def_scheme`); it deliberately does NOT mutate the shared `ports*.json` — `project:add` pre-allocates the full protocol×scheme×env matrix, so the default-protocol port already exists and pruning the set's port would be wrong; a per-env port-presence guard refuses (unless `--force`) when the default-protocol port is missing; `--dry-run` preview, header-preserving JSON rewrite (connector:rm pattern). `bundle:copy <source> <new> @<project>` (+ `cp`) duplicates a bundle under a new name in the SAME project: copies the `src/<source>` tree, then word-boundary-rewrites the name footprint (PascalCase `<Src>`→`<Dst>` for controller class names + lowercase whole-word `<src>`→`<new>` for the gina require-var / `app.json` name / webroot path, `.js`/`.json` files only — embedded tokens like `apiClient` are untouched; a first-bundle webroot `/` is repointed to `/<new>`), allocates a fresh FULL protocol×scheme×env port matrix via the shared `setPorts` (a single-port insert would later emerg in `config.js`, which expects the complete matrix), and clones+repoints the source's manifest entry (`src`/`link`/`releases` target paths). `--dry-run` previews every rewrite site before writing; `--force` overwrites an existing target (its `removeDest` mirrors `bundle:remove`'s deletions). TWO positionals leave `self.name` null (CmdHelper sets it only for a single positional), so the handler reads `self.bundles[0]`/`[1]` directly — which also slips past the `cmd.name`-gated existence guard so the not-yet-registered new name isn't rejected. `bundle:rename <old> <new> @<project>` is the move-sibling — it renames a bundle IN PLACE in the same project (`fs.renameSync` move, NOT a copy) reusing the same `inc/name-rewrite.js` engine but with `fixWebroot:false` (rename moves the only bundle, so there's no first-bundle/collision case; a name-derived `/<old>` webroot is still rewritten by the lowercase pass). Its ports are REKEYED, not reallocated — port NUMBERS are preserved: `ports.json` rewrites the `<old>@<project>/` owner prefix back into the SAME `[protocol][scheme][portKey]` slot (avoiding the two bugs `project/rename.js` carried until #B651: the wrong `[protocol][portKey]` slot, and an unanchored `@<project>` replace across the whole reverse file, which also renamed any project whose name starts with the old one), then `ports.reverse.json` is rekeyed (`pr[new]=pr[old]; delete pr[old]`) and flipped LAST as the canonical existence record. It REFUSES a running bundle with NO `--force` bypass (`--force` only overwrites an existing dest); the whole multi-surface mutation (symlink → renameSync dir → rewrite tree → env → manifest → ports → ports.reverse) is snapshot-guarded (the `bundle:add` rollback model) so any post-move failure reverses the dir move and restores env/manifest/ports/ports.reverse from in-memory snapshots. NOTE: the ROADMAP's "fix the help.txt remouve typo" item was stale — no such typo existed.
|
|
852
852
|
- **Two-guard `@<project>` requirement asymmetry in `lib/cmd/helper.js`** — a `project:*` command that should run without `@<project>` must be exempted in BOTH guards: the early task-shape guard (`!/^project\:(list|help|status)/`) AND the later projectName-resolution guard (`!/\:list$/.test(cmd.task)` → widened to also allow `^project:status$`). Exempting only the first leaves the second falling through to cwd-project-inference and a "No project name found" error before the handler runs. `project:list` is the working precedent that null `projectName` survives `loadAssets()`.
|
|
853
853
|
- **Single-bundle CLI handlers read `cmd.name` / `self.name`, NOT `self.bundle`** — CmdHelper sets `cmd.name = cmd.bundles[0]` only when exactly one bundle positional is present (`lib/cmd/helper.js`, the `cmd.bundles.length == 1` branch); `cmd.bundles` is the array for bulk operations. There is no `self.bundle` property. Precedent: `bundle:stop` / `bundle:start` / `bundle:status` all read `self.name`. (Worked example: a sub-agent investigation reported the slot as `self.bundle`; a single read of `helper.js` refuted it before the handler shipped — verify agent-relayed property names against source.)
|
|
854
854
|
- **`gina start` does not need sudo with a user-prefix install** — `npm install -g --prefix ~/.npm-global` (standard Gina setup) runs as the current user and doesn't touch `/var/run/`. The "Needs to be launched as [sudo]" comment in `lib/cmd/framework/start.js` only applies to system-wide installs.
|
|
@@ -866,11 +866,11 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
866
866
|
- **CLI Tier 3 — project-tree relocate / archive / restore (`project:move` slice 1 `1c492241`, `project:backup` slice 2 `4fed7de8`, `project:restore` + `lib/archiver.decompress()` slice 3 `68563976`; shipped 0.5.x)** — `project:move @<p> --to=/new/path` atomically `renameSync`-moves the source tree and rewrites ONLY the registry `path` (inverse of `project:rename`); refuses while any bundle runs (no `--force` bypass), refuses cross-filesystem (`EXDEV` → use `project:import`). **THE load-bearing gotcha — `--path` is unusable as a "must-not-exist target" for ANY `project:` command:** the shared `CmdHelper` bootstrap ("Creating project location if not existing") pre-`mkdirSync`s `cmd.params.path` for every `project:` command AND points `projectLocation` at it, so a move/restore target passed via `--path` is auto-created and the handler's "target must not exist" check then refuses on the bootstrap's own dir — take the target via a flag the bootstrap never reads (`--to`), whitelisted in `project/arguments.json` (this DIRECTLY bites `project:restore`, whose help advertised `--path`). Three facts make a path-only move minimal: `ports.json` is NAME-keyed (`"<port>": "<bundle>@<project>/<env>"`), so a name-unchanged move never touches `ports*.json` (a negative `self.ports`-absence pin locks this); the `projects.json` path-derived fields (`bundles_path`/`releases_path`/…) live under `~/.<project>` (homedir, name-keyed), so only the `path` field is rewritten; use `renameSync` (atomic, symlink-preserving, throws `EXDEV` cross-fs), NOT `folder.mv` (cp+rm, dereferences symlinks). `project:backup @<p> [--out=/dump]` archives the SOURCE to `<out>/<p>-<YYYYMMDD-HHMMSS>.zip`, read-only on the registry. **THE archiver gotcha** (`lib/archiver` was COMPLETELY UNUSED before this — "compress() works" was an unverified inherited claim): the directory form `compress(dir, target)` is BROKEN for a project tree — `browse()` walks with `fs.statSync`, which FOLLOWS symlinks, so it recurses the project's absolute `node_modules/gina` symlink into the entire framework install AND leaks absolute host paths (and until #B474, 0.6.27, its top-level loop started at index 1, silently dropping the first `readdir` entry — a one-file directory archived to an EMPTY zip); use the ARRAY form `compress([{input,output},…], target, {name,method:'gzip',level:9})` (deterministic `<target>/<name>.zip`, entries at the `output` relative paths, no wrapper folder) with a self-walk that EXCLUDES `node_modules` by name and SKIPS symlinks via `dirent.isSymbolicLink()`. `--with-password` is REJECTED fail-closed — every encryption hook is an empty stub and JSZip can't make encrypted zips, so returning a plaintext archive for an encryption request is a false-security footgun. `project:restore @<name> <archive.zip> --to=/path [--force]` extracts then RE-REGISTERS so the project is immediately startable: `lib/archiver.decompress()` (was an empty stub; now JSZip-3.x — `JSZip.loadAsync` → per-entry `entry.async('nodebuffer')`, skip `entry.dir`, **zip-slip guard** rejecting any entry whose resolved dest escapes the target) → self-invoke `project:add @name --path=<to>` (self-resolved `bin/cli` via `process.execPath`, NEVER a PATH-resolved `gina`) → loop `bundle:add <b> @name --import` over `manifest.bundles` (the only path that allocates a bundle's ports — bare `project:add` allocates 0; `project:import` REFUSES an unregistered name). Uses the explicit `@<name>` CLI shape (cheaper than the no-`@` form help advertised — only the not-found guard needs a `restore` exemption vs 3 guard exemptions otherwise — and `@name` IS the rename-on-restore target). **`lib/archiver` completion contract (#B473, 0.6.27):** each `compress()`/`decompress()` call settles its own `{ onComplete }` handle EXACTLY ONCE on a per-call channel (a hoisted Promise latch behind the handle — the connectors' #B429 shape; a listener attached after settlement is still delivered, and a fire-and-forget failure stays silent, never an unhandledRejection) — before 0.6.27 every completion was ONE fixed event name emitted on the process-wide singleton, so two overlapping calls in one process both received whichever finished first (the second released early with the first's archive path, its own archive left at 0 bytes) and the array branch's implicit-global `outputStream` let one run close another's stream. The singleton STILL emits `archiver-<method>#complete` / `archiver-decompress#complete` `(err, target)` on settlement, purely for observers (documented; keep it). Errors on every stream the lib opens — input reads, the JSZip generate stream, the output write — are OWNED by the call and reach `onComplete` as the stream's error: measured before the fix, an unreadable input either CRASHED the process (unhandled 'error') or HUNG the run with a corrupt partial archive on disk — a timing race: 6 repeated runs of one sole-entry input split 4 hangs / 2 crashes, and a non-first entry hung every time (JSZip only sometimes re-surfaces a read failure on its output stream — so listening on the generate stream alone does NOT fix the hang; owning the input streams does), and an unwritable target crashed both the array and directory forms. Synchronous error paths (missing src, neither-file-nor-dir) reach the handle via the latch rather than firing before it is returned (`decompress()` keeps its explicit `process.nextTick` deferrals). Both promisify shapes work (trailing cb with a `method`-less options object; `compress(src, target, cb)` with options omitted — previously the listener was keyed `archiver-undefined#complete` / `merge()` threw writing onto the function). `options` is copied before `merge()` (which MUTATES its target; an omitted options used to alias the shared defaults object). An application callback that THROWS inside `onComplete` stays an uncaught EXCEPTION — the shim's derived promise rethrows on `process.nextTick` — never a rejection that a non-default `--unhandled-rejections` policy would swallow (the connectors' `.onComplete` shims do NOT have this guard, #B477). Server-only (fs/zlib) → no plugin rebuild; pickup = restart. Deferred, staked: an abort surface (`options.signal`) — no precedent in the tree, on-demand; **#B476 (0.6.27):** the single-file form is now the array form with one entry named by the file's basename — a real DEFLATE zip that `decompress()` reads back, at the same `<target>/<name>.zip` path (`<name>` defaults to the basename; a missing input still settles `file not found`). It used to pipe the file through a gzip codec under the `.zip` name, and a dot-prefixed segment anywhere in the input path (`.env`, or a file under a dot-directory) hit an early return placed BEFORE the streams were tracked: success reported with the INPUT path as the archive, an empty `<name>.zip` left on disk, two fds leaked per call — measured on the shipped bytes, with the directory form (which includes dotfiles) as the control. `method` validates against `gzip` alone: it names the completion event and no form selects a codec by it, so `br`/`deflate` throw synchronously again, as they did in 2019 before a `default:` switch arm silenced them. Server-only; pickup = restart. **Positive-evidence lesson:** all 26 of `project:move`'s source-pin/pure-logic tests PASSED while it was functionally BROKEN by the `--path` collision — only a live `add→move→verify` HTTP-200 smoke caught it, so a new CLI command needs a live happy-path smoke before "done". `project:rename @<old> @<new>` renames only to a FREE name — a registered name or an existing target directory is refused with exit 1 before anything moves — and renames the port entries exactly (`ports.reverse.json` keys `<bundle>@<old>`, `ports.json` values under protocol → scheme → port), never by prefix (#B651; its check used to be inverted, so the one rename it performed was onto another project).
|
|
867
867
|
- **CLI Tier 3 — `framework:update` state reconcile (slice 4 `93e1798a`) + `<group>:man` inline man pages (slice 5 `ae34b7ec`); shipped 0.5.x, completes CLI Tier 3** — `framework:update [--to-version=<v>] [--fix|--apply] [--dry-run] [--format=json]` is a **state-migration** command, NOT the "npm self-update" the ROADMAP once advertised (that was declined — can't update the running process, PATH/sudo hazards; deferred behind a future `--self-update`). It reconciles `~/.gina/main.json` (`def_framework` + `frameworks[<short>]`) and `<short>/settings.json` (`version` + `def_framework`) to `GINA_VERSION` (or `--to-version`), automating the manual Post-merge state check; dry-run by default, `--fix`/`--apply` writes. **Writes MUST go through `lib.generator.createFileFromDataSync(obj, path)`** — that routes a `StateStore.isStatePath` match to `StateStore.write` → SQLite key + atomic JSON sidecar IN LOCKSTEP (so reading the sidecar via `requireJSON` while writing via `createFileFromDataSync` repairs BOTH a stale `def_framework` AND a prior `gina.db`-vs-sidecar drift in one shot); a raw `fs.writeFileSync` is the `post_install.js` bug that drifts `gina.db` behind the sidecar. No-CmdHelper handler (models `framework/set.js`/`version.js`, reads bare `GINA_HOMEDIR`/`GINA_VERSION` globals) — the right shape for a no-project framework command, and it sidesteps the `--path` bootstrap-mkdir gotcha entirely (no project arg). Never-regress guard reuses `script/version_compare.isStrictlyOlder` via `require('../../../../../script/version_compare')` (the relative depth is invariant across framework-dir renames, and `version_compare.js` ships to npm). **Is `def_framework` runtime-load-bearing? — outcome (c), runtime-read but NARROW:** `helper.js` keys `mainConfig.protocols[short]`/`schemes[short]` off it to default protocols/schemes for projects that pin no `framework` AND declare none of their own, but scopes/envs/cultures default off `GINA_SHORT_VERSION` not `def_framework`, and the drift only bites at shortVersion (0.4→0.5) not patch granularity; per-shortVersion metadata-map seeding stays `framework:init`'s job (the command WARNS when absent, locked by a negative-invariant test). **Minor-crossing migration (`init.js` `checkIfMain`, #B680/#B681, fixed in 0.7.0):** on the first run of a new short version, every per-version `main.json` dict with no entry for the release gets a copy of the previous short's entry (previous short = the highest `^\d+\.\d+$` key of `frameworks` other than the release — `latest` never qualifies). The copy fills MISSING entries only, so it is idempotent and independent of `frameworks[<release>]`: bin/cli's `bundle|project:start|restart` sync and every `bin/gina-container` boot write that key BEFORE init runs, and gating the copy on its absence (pre-0.7.0) left `def_culture[<release>]` undefined, so every later command died on `defCulture.split`. `frameworks[<release>]` is seeded from the template only when absent (a registrar's entry is kept). After its commit, `checkIfMain` deletes `main.json` from `require.cache`, so the later steps' `require()` reads the migrated file — `whisper()` returns a NEW object, the cached one is the pre-migration file, and `checkArch` threw once per crossing. The delete is bounded: init's check chain runs from `onComplete` only (once per CLI process or framework start), never per online command in the daemon (`onListen` never calls it). `gina framework:man` / `project:man` / `bundle:man` / `service:man` **runtime-render** the group's ronn `.1.md` page to the terminal (design fork over a build-time roff generator + `package.json "man"` field — no new markdown/man dependency, none is installed), falling back to that group's `help.txt` with a one-line notice when the `.1.md` is absent (only `gina-framework.1.md` exists today, so `project`/`bundle`/`service` fall back). The cross-group engine lives at `lib/cmd/man-render.js` (sibling to `helper.js`, required as `require('../man-render')` — NOT a per-group `inc/`, because it serves 4 groups; the group comes from `opt.task.topic`, so a future `<group>:man` falls back automatically), and each `lib/cmd/<group>/man.js` is a one-line `module.exports = require('../man-render')`. **Lazy `console` keeps the module require-testable** — `man-render.js` reads `getPath`/`GINA_VERSION`/`lib.logger` lazily INSIDE the handler body, never at module top, so a unit test can `require()` it to exercise the pure helpers (`substitute`/`stripRonn`/`renderMan`) without throwing `lib is not defined` (a top-level `var console = lib.logger` would).
|
|
868
868
|
- **`gina version` reports the framework DEFAULT template engine only** — `version` is a context-free global command (no bundle/project config loads), so its `Template engine:` banner line (a `%engine%` msg-token read from the framework-dir engine package, try/catch-omitted when the package is absent) always names the default engine; a bundle's per-bundle opt-in engine is not visible to it. `--short` is unaffected.
|
|
869
|
-
- **Never self-invoke the CLI via a PATH-resolved binary; assert postconditions, not a noisy err channel** — spawn the running install's OWN entry point (`process.execPath` + the self-located `bin/cli`; daemon-lifecycle commands go through the daemonizing `bin/gina` wrapper to keep detached-spawn semantics); PATH may carry no copy or a DIFFERENT install. The Shell helper reports ANY stderr output as an error, so decide success on the operation's POSTCONDITION (e.g. the created symlink exists), and route genuine failures through the error callback so the command exits non-zero. Never guard `execSync` with `instanceof Error` — it THROWS on non-zero exit and never returns an Error, so that branch is dead code; wrap in try/catch and prefer `err.stderr.toString().trim()` for the operator-facing message. Deliberate exceptions: deriving an npm install PREFIX from `which gina` (registry metadata, not an invocation) and `npm` itself (a third-party binary — PATH is the normal resolution — but it runs from an argument vector, `execFileSync('npm', [...])`, never `$(which npm)` in a shell line, which split an npm installed under a path containing a space, #B663). **A self-invoked child is also HOME-BLIND unless the env is re-exported:** the CLI bootstrap sweeps every `GINA_*`/`VENDOR_*`/`USER_*` key out of `process.env` into the framework context (read back via `getEnvVar`), so a spawned CLI child inherits NONE of them — under a `GINA_HOMEDIR` override (CI scaffolds, isolated smokes) the child resolved the DEFAULT home, missed the just-registered project, and `project:add`'s link step failed with exit 1 after the registration itself had landed. Fix shape: the Shell helper takes an opt-in `env` option (undefined = inherit, existing callers unchanged) and the link spawn passes `Object.assign({}, process.env, { GINA_HOMEDIR })`; on a postcondition failure, append the tail of the child's captured stdout to the error — the Shell temp logs are deleted on close, so without it the child's real failure reads as an empty `error: false`. **The sweep blinds EVERY spawned CLI child — the full spawn-site set now re-exports the home:** the auto-link commands run on project/bundle start/stop/restart, the `project:start/stop/restart` bundle delegations (whose command token is substituted from the parent's own argv — already self-resolved, but the exec options carried no env), and `project:add`'s `--scope`/`--env` children. The scope/env children were doubly broken besides the blindness: they shelled out to a PATH-resolved `gina` (a second install or a bare CI PATH resolves wrong — now the self-resolved CLI), and `execSync` with inherited stdio returns null, so reading `.toString()` off that return threw INSIDE the try after the child had SUCCEEDED — every `--scope`/`--env` run misreported `could not be set`, exited 1, and never printed the success message, override or not. Measured pre-fix under a home override: the scope/env children registered the scope in the DEFAULT home's config (propagating into every project registered there), and the home-blind link children silently fabricated scaffold artifacts — a manifest/env "fix" plus node_modules links — under the default home and the invoking cwd, with exit 0. **Never through a shell string either (#B640):** those `--scope` / `--env` children spliced the value unquoted into an `execSync` command line, so shell syntax in it ran before `scope:add`'s own name check saw it; they now run `execFileSync(process.execPath, [cli, task, value])`. Any self-invocation carrying a value that is not a constant takes an argument vector. #B663 applied it to the rest of the children that carried a path: `port:reset` reads its bundle list in-process from the manifest `loadAssets()` loaded (`bundlesByProject[<project>]`, the keys `bundle:list` prints) instead of a `$(which gina) bundle:list` child, which crashed the reset (a null list, exit 1) whenever `gina` was not on PATH; `bundle:restart` runs `bundle:stop`, then `bundle:start` only once the stop succeeded, as two `execFile` calls from `process.argv[0..1]`, each flag its own argument; and the install scripts run their `npm` probes and `framework:set` calls from argument vectors and create `~/.profile` with `fs` — a home path containing a space failed a first install (a no-op `chown -R $(whoami)` on the just-created `~/.gina`, now removed, and a `source` of the profile in a bash child, which never reached the user's shell, also removed).
|
|
869
|
+
- **Never self-invoke the CLI via a PATH-resolved binary; assert postconditions, not a noisy err channel** — spawn the running install's OWN entry point (`process.execPath` + the self-located `bin/cli`; daemon-lifecycle commands go through the daemonizing `bin/gina` wrapper to keep detached-spawn semantics); PATH may carry no copy or a DIFFERENT install. The Shell helper reports ANY stderr output as an error, so decide success on the operation's POSTCONDITION (e.g. the created symlink exists), and route genuine failures through the error callback so the command exits non-zero. Never guard `execSync` with `instanceof Error` — it THROWS on non-zero exit and never returns an Error, so that branch is dead code; wrap in try/catch and prefer `err.stderr.toString().trim()` for the operator-facing message. Deliberate exceptions: deriving an npm install PREFIX from `which gina` (registry metadata, not an invocation) and `npm` itself (a third-party binary — PATH is the normal resolution — but it runs from an argument vector, `execFileSync('npm', [...])`, never `$(which npm)` in a shell line, which split an npm installed under a path containing a space, #B663; since #B665 S2 no runtime, bootstrap or install-script line does, and only the release scripts' `$(which npm) pkg set` / `install` calls in `script/post_publish.js` still do). **A self-invoked child is also HOME-BLIND unless the env is re-exported:** the CLI bootstrap sweeps every `GINA_*`/`VENDOR_*`/`USER_*` key out of `process.env` into the framework context (read back via `getEnvVar`), so a spawned CLI child inherits NONE of them — under a `GINA_HOMEDIR` override (CI scaffolds, isolated smokes) the child resolved the DEFAULT home, missed the just-registered project, and `project:add`'s link step failed with exit 1 after the registration itself had landed. Fix shape: the Shell helper takes an opt-in `env` option (undefined = inherit, existing callers unchanged) and the link spawn passes `Object.assign({}, process.env, { GINA_HOMEDIR })`; on a postcondition failure, append the tail of the child's captured stdout to the error — the Shell temp logs are deleted on close, so without it the child's real failure reads as an empty `error: false`. **The sweep blinds EVERY spawned CLI child — the full spawn-site set now re-exports the home:** the auto-link commands run on project/bundle start/stop/restart, the `project:start/stop/restart` bundle delegations (whose command token is substituted from the parent's own argv — already self-resolved, but the exec options carried no env), and `project:add`'s `--scope`/`--env` children. The scope/env children were doubly broken besides the blindness: they shelled out to a PATH-resolved `gina` (a second install or a bare CI PATH resolves wrong — now the self-resolved CLI), and `execSync` with inherited stdio returns null, so reading `.toString()` off that return threw INSIDE the try after the child had SUCCEEDED — every `--scope`/`--env` run misreported `could not be set`, exited 1, and never printed the success message, override or not. Measured pre-fix under a home override: the scope/env children registered the scope in the DEFAULT home's config (propagating into every project registered there), and the home-blind link children silently fabricated scaffold artifacts — a manifest/env "fix" plus node_modules links — under the default home and the invoking cwd, with exit 0. **Never through a shell string either (#B640):** those `--scope` / `--env` children spliced the value unquoted into an `execSync` command line, so shell syntax in it ran before `scope:add`'s own name check saw it; they now run `execFileSync(process.execPath, [cli, task, value])`. Any self-invocation carrying a value that is not a constant takes an argument vector. #B663 applied it to the rest of the children that carried a path: `port:reset` reads its bundle list in-process from the manifest `loadAssets()` loaded (`bundlesByProject[<project>]`, the keys `bundle:list` prints) instead of a `$(which gina) bundle:list` child, which crashed the reset (a null list, exit 1) whenever `gina` was not on PATH; `bundle:restart` runs `bundle:stop`, then `bundle:start` only once the stop succeeded, as two `execFile` calls from `process.argv[0..1]`, each flag its own argument; and the install scripts run their `npm` probes and `framework:set` calls from argument vectors and create `~/.profile` with `fs` — a home path containing a space failed a first install (a no-op `chown -R $(whoami)` on the just-created `~/.gina`, now removed, and a `source` of the profile in a bash child, which never reached the user's shell, also removed). #B665 S1 did the same for the daemon-reachable and name-fed children: the auto-link `link-node-modules` / `link` children run `execFileSync(process.execPath, [cli, task, '@' + project].concat(cmd.paramsArgv))` — `paramsArgv` is the list twin of `paramsStringified`, which a shell split on any value holding a space; `project:start/stop/restart` delegate through `execFile(argv[0], [argv[1], 'bundle:<verb>', '@' + project, ...flags])` (their joined runtime + CLI paths broke on an install path containing a space); `bundle:start`'s `node_modules` reinstall re-links with this install's own `bin/gina` under `runtimeBinary(process.execPath)` instead of `which gina`; `framework:link`'s repair step takes an argument vector; and the `ps` sweeps of `bundle:stop` / `minion:kill` run `execFileSync('ps', ['-ef'])` with a 64 MB `maxBuffer` (the listing now arrives whole, no longer pre-filtered by `grep`) and match the title in JS, escaped and ending at whitespace or the end of the line — the old substring made `api@shop` match `api@shopping`, and a slim image with no `ps` reads as no match, as before. The two never-called `restartRunningBunldes()` copies were removed. #B665 S2 took the operator-argv children the same way: `framework:status` lists only the current user's `gina-v<version>` daemons through `lib/cmd/framework/inc/ps-titles.js` (`ps -f -U <uid>` from an argument vector; it had turned ANY user's `gina-`-titled process title into a pid-file name, and `_()` normalises `..`, so `gina-v/../../x` wrote outside the run dir, #B677) and checks liveness with `process.kill(pid, 0)` (EPERM = alive), so a `ps`-less host no longer drops every live pid file; `bundle:stop` accepts only a positive integer from its pid file and signals with `process.kill` (`parseInt('-1')` made `kill -9 -1` a broadcast, #B693); `framework:restart` / `build` / `add` / `open`, `gina .`, `inspector:open`, the npm prefix fallbacks of `utils/helper.js` and `framework:init`, and isaac's boot-time `which` + brotli/gzip compressions all take argument vectors, while Windows keeps the cmd.exe lines (`start`, `where`, `npm.cmd`; #B694). #B665 S3 closed the class. A NEW project or bundle name must pass the scope/env name rule (`lib/cmd/project/inc/name.js`, `lib/cmd/bundle/inc/name.js`: `^[a-z0-9_.][A-Za-z0-9_.-]*$`, not `.`/`..`, not an inherited property name such as `constructor`), checked before anything is written: at project:add, at bundle:add (the whole list, before the first bundle), at project:rename, at bundle:copy/rename's destination, and at project:restore (before extracting). A name already registered as an own key is exempt, and project:import skips the check, so names registered before the rule keep working. Every registry lookup that builds a RegExp from a name escapes it (`escapeRegex`, `lib/cmd/bundle/inc/name-rewrite.js`), and the nine that also matched a sibling are anchored on the registry's shapes: `^<bundle>@<project>/` for values, `^<bundle>@<project>$` for reverse keys, and the value's `JSON.stringify` inside JSON text. Unanchored, `api@shop/` matched `myapi@shop/dev`, `@shop` matched `@shopping/`, and `/dev` matched `/devel`. The install scripts carry the same one-line escaper, since they cannot rely on a framework dir. **And never let two CLI processes overlap (#B647):** every gina CLI process reads and rewrites the registry files in `~/.gina`, so concurrent CLI children lose each other's writes (the state store logs `database is locked`, then a stale rewrite wins). `project:add` started its asynchronous `link` child and then ran the `scope:add` / `env:add` children alongside it, and a new scope went missing on 3/100 measured runs; `end()` now defers the link step with `setImmediate` until the synchronous scaffold (the children, `manifest.json`, `env.json`) is done — 0/100, and no lock warning. **A handler's success path must END the process** (`process.exit` / `end()`): wherever `bin/cli` runs by its own path inside a directory named `gina` (`…/node_modules/gina/bin/cli` — handler-spawned children, CI, scripts) it binds the MQ log listener, which keeps the event loop alive, so a handler that only returns hangs there while a checkout run exits (#B648, #B653); exit only after a listing's stdout write has flushed. A test for this class runs the CLI through a symlink NAMED `gina` with its own `GINA_MQ_PORT`, and asserts the listener line as a control.
|
|
870
870
|
- **Port pinning beats allocation** — `port:reset` allocates alphabetically and count-sensitively, so hardcoded per-container ports DRIFT whenever the project's bundle set changes; pin each container's own bundle with `port:set <bundle> @<project> --protocol=<p> --scheme=<s> --port=<n> --env=<e> --force` (`--force` evicts the prior holder from BOTH the forward and reverse port maps; without it an in-use port still rejects, byte-identically; idempotent on re-runs; eviction-not-swap — the displaced bundle re-pins itself from its own context). `bundle:add --ignore-ports=<csv>` excludes ports from the scan — the values stay STRINGS end-to-end because the scan's skip is a string `indexOf` compare; `parseInt`-ing them silently breaks the skip.
|
|
871
871
|
- **Framework-connection flags are scoped to framework-scoped commands** — `--port`/`--mq-port`/`--host-v4`/`--hostname`/`--debug-port` are hoisted + persisted into the framework's own settings ONLY for bare `start`/`stop`/`restart` and `framework:*` commands; a sub-topic command's `--port` stays on argv for that command's own parser. Pre-fix they hoisted for EVERY command, so a bundle-scoped `--port=N` corrupted the framework command-socket port (8124 → N) — and because the daemon binds the PERSISTED `settings['port'] || 8124` directly (no port-scan), clearing runtime state does NOT recover it: correct the persisted `port` key in BOTH state surfaces (JSON sidecar + its database mirror), keyed by shortVersion `major.minor`. The `--inspect`/`--debug` splice stays unconditional. Measurement lesson: instrument the WRITE (a stack-dump on the settings write) to find the real hoister — reading the hoisters mis-identified the site once.
|
|
872
872
|
- **The daemon command socket accumulates-and-guard-parses** — TCP may split or coalesce the client's single JSON argv write, so the socket handler keeps a per-connection accumulator, parses inside a SILENT try/catch (a partial chunk legitimately fails until complete), and shape-guards the result (`Array.isArray`) before use; a bare `JSON.parse(chunk)` was a crash vector (SyntaxError → uncaughtException → SIGTERM daemon teardown).
|
|
873
|
-
- **`tail --follow`'s auto-restart treats log-derived identifiers as UNTRUSTED and the saved-argv file as trusted by OWNERSHIP only** — the bundle/project parsed from a crash log line rejects path separators + `..` traversal and confines the resolved saved-argv path under the argv dir (`getArgvDir()` = `<GINA_HOMEDIR>/run`, created `0700`; never the shared tmp dir, where another local user can pre-create the file so the owner's write fails and the restart runs theirs — #B676); `bin/gina` writes the file `0600`, and `getBundleStartingArgv()` refuses with a warning (returns `null`) any file that is not a regular file owned by the current uid or that group/other can write; the saved start command re-executes via `execFileSync(bin, args)` (no shell), gated on the argv containing `bundle:start`. Any auto-action that executes a command derived from network/log-sourced input or read from a file takes all three guards: confine the path, trust the file by ownership, exec without a shell.
|
|
873
|
+
- **`tail --follow`'s auto-restart treats log-derived identifiers as UNTRUSTED and the saved-argv file as trusted by OWNERSHIP only** — the bundle/project parsed from a crash log line rejects path separators + `..` traversal and confines the resolved saved-argv path under the argv dir (`getArgvDir()` = `<GINA_HOMEDIR>/run`, created `0700`; never the shared tmp dir, where another local user can pre-create the file so the owner's write fails and the restart runs theirs — #B676; since 0.7.1 the CLI's `mq-listener-v<version>.port` file lives there too, written `0600` by `bin/cli` and read by `tail` — in the shared tmp dir another user's pre-created copy made every other user's CLI command fail at boot, #B704); `bin/gina` writes the file `0600`, and `getBundleStartingArgv()` refuses with a warning (returns `null`) any file that is not a regular file owned by the current uid or that group/other can write; the saved start command re-executes via `execFileSync(bin, args)` (no shell), gated on the argv containing `bundle:start`. Any auto-action that executes a command derived from network/log-sourced input or read from a file takes all three guards: confine the path, trust the file by ownership, exec without a shell.
|
|
874
874
|
- **`framework:reset` (`gina reset`) is the runtime factory reset** — wipes `~/.gina` so the NEXT command rebuilds it from defaults; the install-lifecycle `--reset` flag can't work under a package manager that blocks dependency lifecycle scripts. It deliberately does NOT re-bootstrap in-process (this process's require cache holds the just-wiped state files — an in-process rebuild would read stale config). It refuses while the daemon or any bundle runs unless `--force`, liveness-probed via `process.kill(pid, 0)` — never a `ps` shell-out: on ps-less minimal images, every `ps -p` throw made the pidfile sweeper prune LIVE bundles' pidfiles on every command (bundles reported stopped, the reset guard blinded).
|
|
875
875
|
- **`framework:add` / `framework:list` / `framework:remove` manage side-by-side installed versions** — an install ships exactly ONE framework tree; side-by-side versions live in a user-home archive store symlinked into the install (re-linked on every install), and a bundle pins one via `--gina-version` / manifest `gina_version`. `add` (pack → extract → archive → own-deps install → symlink-unless-a-real-dir → register) NEVER writes the default-version key — adding ≠ defaulting; `remove` HARD-refuses the active default + the real shipped dir (only symlinked versions are removable) and SOFT-refuses a project manifest pin (`--force` overrides); `list` reconciles the three surfaces (install dir real-vs-symlink, archive store, registry). `project:status` / `bundle:status --format=json` carry a per-project `framework` field + a per-bundle `gina_version` field — the effective per-bundle version is `gina_version || framework`.
|
|
876
876
|
|
|
@@ -920,7 +920,7 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
920
920
|
|
|
921
921
|
175. **Dev-mode hot-reload & module lifecycle — what reloads, what doesn't, and the `require.cache` poisoning antipattern** (replaces individual entries #22, #25, #28, #34, #104) — in dev mode (`NODE_ENV_IS_DEV` set; `isCacheless()` true) the framework hot-reloads code via two functions with DIFFERENT triggers: `refreshCoreDependencies()` (`core/router.js`) evicts and re-requires the controller pair ONLY when the dev watcher has marked a watched file dirty (`__hotReload` flags; watched: `controller.js`, `controller.render-swig.js`, and the bundle's `controllers/` directory; falls back to per-request eviction when the watcher context is absent), while `refreshCore()` (`core/server.isaac.js`, isaac engine only) re-exports core-path modules and re-requires `lib/index.js` + `plugins/index.js` on EVERY request. Consequence for controllers: module-level state (`var store = {}`) resets whenever a watched file changes (not per request) — for in-process state that survives hot-reloads (but resets on `bundle:restart`) attach to `global` (`if (!global.__myStore) global.__myStore = {}; var store = global.__myStore;`); for durable state use a database or file. NOT hot-reloaded (a full `gina bundle:stop` + `bundle:start` or `docker restart` is required): `server.js` / `server.isaac.js` / `server.express.js` (loaded once at process start, never evicted); connector code (`core/connectors/*/index.js`, loaded once via entity registration, outside the refresh scope); and bundle-registered plugin middleware — the `onInitialize` → `app.use(gina.plugins.X(...))` factories run ONCE at bootstrap, so a plugin config change needs a bundle restart in BOTH dev and prod, despite the misleading per-request refresh cue. Correctness invariant for any eviction code: `require.cache[path]` must hold a `Module` instance — `require.cache[path] = require(path)` poisons the slot by storing the bare exports object (no `.exports` key), so the next plain `require()` of that path returns `undefined`, surfacing as `Cannot read properties of undefined (reading '<X>')` after a hot reload; use `delete require.cache[require.resolve(path)]` + the `require()` return value, or swap `require.cache[c].exports = require(path)` on the existing Module — never the bare assignment. **The eviction cycles also leaked the whole module graph (#B32, folds former #173):** Node pushes every cache-miss require's fresh Module onto the REQUIRING module's `children` array and dedupes only on cache hits, so the per-request delete-and-re-require cycles accumulated one dead Module per eviction on long-lived parents — each pinning its entire evaluated exports graph (~1.8 MB post-GC live heap per request on a minimal dev bundle; heap-limit OOM/SIGABRT at ~2400 requests, presenting upstream as HTTP/2 PING timeouts, then ECONNREFUSED, then a supervisor respawn loop that keeps every process cold). Fixed by a `pruneDeadModuleChildren()` sweep at the end of BOTH eviction cycles — `children` is diagnostic metadata (nothing in Node resolution reads it), so pruning never unloads a module still referenced elsewhere; prod was never affected (no eviction + cache-hit dedup). The sweep walks `require.cache` keys ONLY, so a second residual of the same class existed OFF-cache: hot-evicted leaf/singleton libs captured at gen-0 by load-once modules and re-required per request pushed dead children onto the evicted-but-retained gen-0 parent, prune-blind — those libs are plain-`require`d now (never evicted → cache-hit → children deduped), and a completeness audit closed the gen-0-binding class. Rules: any new delete-`require.cache` + re-require cycle in a long-lived process must end with the prune sweep; a hot-evicted lib captured as a gen-0 binding by a load-once module leaks PAST the prune — plain-require leaf/singleton libs that don't need hot reload. **`requireJSON` has NO cache in ANY mode** — every call is `fs.readFileSync` + comment strip + `JSON.parse` (its dev-mode `require.cache` eviction touches a cache it never fills), so never call it per request in bundle code; read a config once at module load or in `onReady`. The framework's own env template (`core/template/conf/env.json`) is parsed once per process and shared read-only by every `Config` instance since #B610 (0.6.33) — before that, `new Config()` (which runs three times per request) re-read it twice per construction, six disk reads per request.
|
|
922
922
|
|
|
923
|
-
176. **Inspector & `/_gina/*` built-in endpoints — in-process architecture, admin IP-allowlist, agent-stream auth, live index coverage** (replaces individual entries #31, #32, #38, #111, #134, #138) — the dev Inspector (formerly Beemaster) is a built-in SPA served at `/_gina/inspector/` inside the bundle's own process: no project registration, no separate port, no auto-start spawn; dev-mode only (production bundles never expose it), and same-origin with the monitored bundle so `window.opener.__ginaData` always works. Every `/_gina/*` route (healthcheck, assets, cache/stats, info, inspector, logs, agent, indexes, reveal, instrument, metrics) is a handler in the same HTTP server process; the Isaac engine is the source of truth and may carry fast-paths, but base functionality belongs in the engine-agnostic dispatcher so Express bundles get the same endpoints. Admin-grade endpoints exposing process/cache internals (`/_gina/info` — memory/uptime/version/HTTP-2 session counters; `/_gina/cache/stats` — full cache contents) are IP-allowlisted via the `admin.allowFrom` block in `app.json`: the client IP is read from the socket only (the spoofable `X-Forwarded-For` is never trusted), `::ffff:`-mapped IPv4 is normalised, the list defaults to loopback (`127.0.0.1`, `::1`) when omitted, an empty list denies everyone, and denied callers get a 403 JSON error; `/_gina/health/check` stays deliberately open for liveness probes, and `/_gina/metrics` keeps its own separate `metrics.allowFrom` axis. The `/_gina/agent` stream (combined data + log events) is dev-only by default but can be enabled outside dev behind an API key (`settings.json > inspector.agent.{enabled, key}`, `${secret:KEY}`-capable, constant-time compared, fail-closed when no key is configured); browsers pass `?key=` as a query param because `EventSource` and WebSocket handshakes cannot set custom headers — and any `$`-anchored endpoint gate regex must become `(?:\?|$)` the moment its endpoint accepts a query param, or the handler silently stops matching the query'd URL. A time-boxed, separately-keyed production instrumentation window (`POST /_gina/instrument`, hard-capped at one hour) can stream per-request query + flow capture over that authenticated channel — channel AUTH, not redaction, is what protects raw query text (redaction masks only secret-NAMED fields, never the statement or its positional params). The Query tab computes live index coverage client-side, so bundles WITHOUT an `indexes.sql` get a correct "no index for filter" badge on the first render too (cached live-index descriptors are cloned per query before stamping coverage — the cache is shared across queries that filter different columns). The live-index refetch's success re-render lands in `#tree-query` — the container the tab renderer owns and silently bails on when missing — never the scroll wrapper around it (#B222: replacing the wrapper's children destroyed `#tree-query`, so after the first render carrying an `indexes: null` query every later payload — navigation, XHR, the refresh button — froze the Query pane and badge until the Inspector window itself was reloaded; the ⟳ button re-armed the freeze via its forced refetch). Inspector toolbar CSS: native macOS `<select>` ignores `line-height` — use explicit vertical padding, and keep `select` (sans font) and `input` (mono font) on separate CSS rules. **SPA + statusbar hardening (folds former #165/#190/#191/#196):** every per-bundle `/_gina/*` consumer in the SPA derives its base URL via the shared 3-fallback `resolveBundleBase()` (`?target=` → opener pathname → path strip) — a bare `window.location.pathname` strip misroutes to the proxy's default bundle in reverse-proxy multi-bundle setups. The Inspector binds to its opener tab via a per-tab `BroadcastChannel` (`?ch=<tabId>`): pages sending COOP `same-origin` sever `window.opener` for the popup, and the bundle-global fallbacks (the shared localStorage slot + the worker-wide agent SSE) reflect whichever render last touched them — a diagnostic channel that must track ONE page needs a per-tab transport (the statusbar publishes that tab's data at its existing write points and answers a request/reply handshake; no `?ch=` keeps the prior behaviour, and `?target=` agent mode keeps priority). Structured-localStorage reads need a SHAPE check on top of the try/catch parse (a tampered key holding a JSON primitive otherwise breaks the un-guarded consumer), and every value interpolated into `innerHTML` goes through an HTML-escape helper — untrusted model/app text renders via `.textContent` instead. Server data serialised into those inline scripts goes through the shared helper `core/controller/inline-script.js` (`safeInlineJson` / `safeInlineString` / `escapeForInlineScript`), applied at every emission site in both renderers (4 swig + 3 nunjucks): `<` → `\u003c` (the earlier literal-`</script>`+`<!--` blocklist was bypassable — per the HTML5 script-data end-tag-name state `</script >`, `</script/>` and `</SCRIPT >` all terminated the block; #B451, 0.6.23), U+2028/U+2029 (JS line terminators that are legal in JSON), and — inside JSON string literals only — `{`/`}` → `\u007b`/`\u007d` (#B463): render-swig splices the `__ginaData`/`__ginaLogs` scripts into the layout BEFORE `swig.compile` (the per-request nonce there is a swig conditional), so unescaped `{{ }}`/`{% %}`/`{# #}` in stored data were evaluated as template source — `A{{ 7*7 }}B` rendered `A49B`, measured on fork 2.0.0/2.4.0/2.8.0; nunjucks splices post-render and was never exposed. Structural braces are never touched (an escape outside a literal is neither JSON nor JS), every value parses back byte-identical; pins: `test/lib/inline-script-xss.test.js` (jsdom, 11 spellings) + `test/lib/inline-script-template-injection.test.js` (renders through the real fork, with a firing control). Both server-side only ⇒ pickup = restart, no re-bake. Dev inline-script splices before `</body>` use FUNCTION replacers: `String.replace(/re/, str)` expands `$`-sequences in a STRING replacement (`` $` ``/`$'`/`$&`), so dynamic content carrying a stray dollar-sequence spliced the whole document into the statusbar `<script>` (SyntaxError → statusbar and launch link vanish) on content-heavy pages while a near-empty smoke page stayed falsely green — any `String.replace(re, dynamicX)` with dynamic content must use a function replacer or escape `$`. Inspector SPA files are copied VERBATIM to dist (no minification) — an inspector-only edit rebuilds only those dist files, never the main bundle artifacts. Bound-window data fidelity (#B225): a preload-consumed popin open sets the dev toolbar's XHR overlay on BOTH consume branches exactly like a cold click (dev-gated `updateToolbar(body)` immediately before each dispatch — in consumePreload, never in the shared dispatcher, which would double-fire the cold path); the statusbar-bound Inspector window's refresh button never reopens the passive `/_gina/agent` stream (`source !== 'broadcast'` on the reopen gate — bound mode takes page-scoped data from the statusbar publisher, and a leaked stream applied bundle-wide payloads over it, blinking the Query badge at every XHR overlay); and every Query-pane re-render (filter/search/show-all plus the live-index refetch success path) derives from the live payload via renderTab('query')'s own preference (reveal swap included) instead of the module query cache, which the empty render path never clears and which survives overlay eras ending while the Query tab is inactive — a refresh no longer resurrects a closed popin's badge count. A `?ch=`-less embedded Inspector (direct URL, bookmark, a window predating bound mode) no longer silently runs those bundle-global channels (#B231): every dev page's statusbar advertises its per-tab channel id in localStorage (`__gina_last_tab_ch`) on each publish, a `?ch=`-less Inspector adopts the most-recently-published tab's channel at boot behind a liveness handshake (the bind's request must be answered by a data frame within 1.5 s — a stale advert from a closed tab tears the bound mode down and falls back to the legacy acquisition, closing the bound-mode `/_gina/logs` stream so log entries are not double-delivered once the passive agent stream attaches), and a footer badge names the active data-source mode (`bound` / `agent` / warn-tinted `global` with a tooltip pointing at the statusbar link) so a degraded bundle-global mode is visible instead of silent. That badge renders from `updateSourceModeBadge()`, which init must call on BOTH acquisition branches (#B306): it previously ran only on the non-agent branch and from `pollData()`, and since the poll timer is deliberately never scheduled under `?target=` (agent data is SSE-pushed), a URL-entered standalone Inspector kept the element's shipped `hidden` attribute and named no mode at all — while the identical `agent` mode DID render when reached through the connect form, whose timer is already ticking when `source` flips. Badging depended on how the window was opened rather than on the mode itself, which is exactly what the badge exists to prevent. The timer stays unscheduled in agent mode on purpose, and a future reader must not "reconcile" that guard to the comment that once claimed otherwise: `pollData()` is NOT a no-op under `source === 'agent'` — it repaints the active tab from cache before returning — so putting it on a timer would repaint every `pollDataMs` and fight scroll position, expanded folds and text selection; the refresh button calls `pollData()` directly and needs no timer at all. The footer memory gauge sits its unfilled track on `--bg3` with a theme-scoped inset groove shadow (#B237) — the track previously shared `.bm-footer`'s own `--bg2` background token, so the empty portion rendered invisible in both themes and a low fill read as a floating green dot; a gauge track must contrast the surface it sits on, and an inset box-shadow paints under the fill child, so the groove shades only the empty region. The Forms tab shows the page's DOM forms enriched with runtime validation state (the statusbar merge writes `u.forms[<id>]` per form), while the whole-bundle forms catalog the server seeds into `user.forms` (`page.forms` = the walked `<bundle>/forms/` directory groups — `rules`, `mocks`, `validators`, open-ended) renders as one collapsed, muted "Bundle catalog" card at the bottom (#B343): catalog keys are recognized by their presence in the pristine `gina.forms` half of the payload (which runtime merges never write), so app-custom group directories demote too, departed runtime forms (a closed popin's form) keep their own card, and a payload with no gina half falls back to the legacy per-key rendering — nothing is ever hidden. **The page whisper excludes `mocks` (#B344, every env):** `page.environment.forms` — the RFC5987-encoded export the client loader parses into `gina.forms` — ships a SHALLOW COPY of the catalog minus the `mocks` group in every environment (dev fixture data has zero client consumers: the client bundle reads `gina.forms.rules` and `gina.forms.validators` only), trimming page weight and keeping the client contract uniform across envs; server-side `conf.forms`/`page.forms` — and therefore this catalog card — keep the full set, and the shared catalog object is never mutated. **The localStorage fallback channel is re-synced by the late-bind patch (#B386, fixed 2026-08-16, shipped in 0.6.11).** `statusbar.html` mirrors `window.__ginaData` into `localStorage.__ginaData` at statusbar-execution time, which is BEFORE the render delegates append their late-bind patch script above `</body>` — and that patch mutates the IN-MEMORY object only. Without a re-sync the mirror keeps the EMIT-TIME payload forever: `metrics.weightBytes` null (null at emit by construction, since the body length is unknown until the render finishes) and the late flow entries absent. Measured live on a dev bundle: mirror `{serverMs:2339, weightBytes:null}` with 17 flow entries against an in-page `{serverMs:2860, weightBytes:628192}` with 21 — the four missing being `swig-compile`, `swig-execute`, `response-write`, `total`. Two visible symptoms, and the asymmetry between them IS the diagnostic: the View tab drops its weight badge (both weight legs falsy) while the load badge survives, because `serverMs` IS set at emit and `weightBytes` is not — so a fallback-channel Inspector shows 2 badges instead of 3, and only until something else re-syncs; and the Flow tab loses its template-compile/execute/response-write/total bars. All three patch sites (render-swig cache-hit and cache-miss, render-nunjucks) now end their patch script by refreshing BOTH channels the Inspector can read, placed AFTER the metrics assignments — ordering is load-bearing, since syncing first would re-mirror the stale payload. **localStorage alone was NOT enough, and that was the first fix's mistake:** it is only the FALLBACK channel, while a statusbar-launched Inspector runs in BOUND mode on a per-tab `BroadcastChannel`. Measured: all SEVEN `_ginaPublish()` call sites sit BEFORE the patch (`publishesAfterPatch: 0`), and a surrogate bound listener held across a real navigation received exactly ONE frame carrying `weightBytes: null` with `serverMs` set — the 2-badges-instead-of-3 symptom, reproduced on the transport itself. The patch now also republishes there, keying the channel from `sessionStorage.__gina_tab_id` (per-tab, race-free) and NEVER from the `__gina_last_tab_ch` localStorage advert, which is last-writer-wins across tabs. The subscriber applies it immediately (`setupBoundChannel`'s `onmessage` → `_bcLatest = payload; pollData()`). ⚠️ The convention `'gina-inspector-' + tabId` now spans FOUR files (statusbar publisher, SPA subscriber, both delegates) and is pinned by a test, since a rename in one strands the Inspector on a channel nobody publishes to — silently, with no error. **Generalisable: verifying the PRODUCER and inferring the CONSUMER is not verification** — the page data measured correct on all four reported URLs while the bug was fully present. The nunjucks site was also converted to a FUNCTION replacer, which both swig sites already used. **Cross-origin WRITE guard (#B384, fixed 2026-08-16 — a CSRF that was live in every release up to 0.6.9).** The admin endpoints authenticate with an AMBIENT credential (the client IP via `lib.admin.isClientAllowed`), which a browser attaches automatically, so an operator browsing from an allowlisted address — loopback by DEFAULT, i.e. the machine running the bundle — could be lured to a page that silently wrote to `/_gina/storage/gc`, `/_gina/cache/clear`, `/_gina/release/rebuild` or `/_gina/maintenance`. The first three read their whole input from the QUERY STRING and no body, so the attack needed no `fetch` and no CORS reasoning at all: an auto-submitting `<form>` sufficed. `lib.admin.isCrossOriginWrite(req)` now fronts the WHOLE family from ONE site per engine, placed above every `/_gina/*` handler so current and future handlers inherit it — the body reader `_readInstrumentBody` was the WRONG seam (it fronts only 2 of the family), and folding the check into `isClientAllowed` would have silently widened a function whose name promises an IP check. Signals, in order: `Sec-Fetch-Site` (browser-computed, a forbidden header name so page script cannot forge it, and independent of a proxy rewriting `Host`) — `same-origin`/`none` pass, `same-site`/`cross-site` refused; else `Origin` vs `:authority`/`Host`, built NEVER from `X-Forwarded-*` (#B367), with `Origin: null` refused (#CSRF3). NO browser signal ⇒ ALLOWED, since curl / the gina CLI / a deploy script carry no ambient credential. SAFE methods (GET/HEAD/OPTIONS/TRACE) are untouched, so the Inspector's deliberately cross-origin GET/SSE channels keep working — measured: the plugin source issues ZERO POSTs to `/_gina/*`, control firing at 23 hits on `/_gina/agent`. `/_gina/instrument` was NOT the worst of the family as first filed: it is key-gated (`_instrumentKeyValid`), so an attacker without the key cannot invoke it; its `access-control-allow-origin: *` is set BEFORE the key check, making only the 401 cross-origin readable. Verified live: 5 attack arms (incl. the query-param form vector and an `X-Forwarded-Host` spoof) all 403 with state never flipping, 5 control arms all allowed incl. a real browser's `no-cors` POST refused where it previously flipped the toggle. Residual, pinned: a browser sending NEITHER signal on a POST would pass — legacy-only, and narrower than the IP allowlist already fronting these endpoints. #B679 — the isaac `X-Forwarded-Prefix` handler in this same forwarded-header block ran its trailing-slash trim (`/\/+$/`, which backtracks quadratically on a long run of slashes) on the RAW header BEFORE the length+charset gate, on every request: a 15 KB header cost ~100–170 ms CPU (measured on a booted bundle; the regex alone ~8 s at the 64 KB an HTTP/2 header list admits). The gate now runs BEFORE the trim, so a >255 value is zeroed before any regex sees it; a legitimate ≤255 mount path is unchanged. Reflex: any per-request regex over an attacker-controllable header caps length first, then matches.
|
|
923
|
+
176. **Inspector & `/_gina/*` built-in endpoints — in-process architecture, admin IP-allowlist, agent-stream auth, live index coverage** (replaces individual entries #31, #32, #38, #111, #134, #138) — the dev Inspector (formerly Beemaster) is a built-in SPA served at `/_gina/inspector/` inside the bundle's own process: no project registration, no separate port, no auto-start spawn; dev-mode only (production bundles never expose it), and same-origin with the monitored bundle so `window.opener.__ginaData` always works. Every `/_gina/*` route (healthcheck, assets, cache/stats, info, inspector, logs, agent, indexes, reveal, instrument, metrics) is a handler in the same HTTP server process; the Isaac engine is the source of truth and may carry fast-paths, but base functionality belongs in the engine-agnostic dispatcher so Express bundles get the same endpoints. Admin-grade endpoints exposing process/cache internals (`/_gina/info` — memory/uptime/version/HTTP-2 session counters; `/_gina/cache/stats` — full cache contents) are IP-allowlisted via the `admin.allowFrom` block in `app.json`: the client IP is read from the socket only (the spoofable `X-Forwarded-For` is never trusted), `::ffff:`-mapped IPv4 is normalised, the list defaults to loopback (`127.0.0.1`, `::1`) when omitted, an empty list denies everyone, and denied callers get a 403 JSON error; `/_gina/health/check` stays deliberately open for liveness probes, and `/_gina/metrics` keeps its own separate `metrics.allowFrom` axis. **#B709 (fixed for 0.7.1) — a loopback entry admits only a DIRECT caller.** The default list is loopback, and loopback is also the address a reverse proxy on the bundle's own host connects from, so the address-only gate admitted every client such a proxy relayed — maintenance on (a site-wide 503), a cache flush, a storage gc, the process and cache state — with no browser and no credential; an edge block of `/_gina/` did not close it either, because the unanchored handlers also matched a nested endpoint path (`/_gina/health/check/_gina/maintenance`, which a PREFIX proxy location forwards), the endpoint path in a query string, another letter case, or `//_gina/…`. Now (1) `lib.admin.isClientAllowed` and `lib.metrics.isClientAllowed` refuse a listed LOOPBACK caller whose request carries a proxy signal — the classifier the maintenance bypass already used (`lib.maintenance.isProxiedRequest`: any `x-forwarded-*` header name, RFC 7239 `Forwarded`, a `true` #B65 stamp, or a port-less `Host`/`:authority` unless `server.proxy.requireForwardedHeaders` is true); a listed NON-loopback address (a proxy on another host) still admits what it relays, and the first refusal per list is logged once per process. (2) That classification is stamped ONCE per `/_gina/*` request from the pristine headers at both engine tops (`request._ginaAdminProxied`, first-seer): isaac rewrites an h1 `Host` port-less before it hands a request to server.js, so a live read there would take every direct caller of a server.js-only handler (`storage/*`) for a proxied one. (3) `info`, `cache/stats`, `cache/clear`, `maintenance` and `metrics` match EXACTLY on `lib.admin.controlPath(url, webroot)` — the query-free path, in the root form or the bundle's own webroot form, nothing else (a query on these endpoints is now ignored, where it used to 404 some of them); `storage/*` and `release/*` stay `^`-anchored on the url and become case-sensitive; `health/check` and the dev/key-gated family are unchanged. Residual, pinned: a proxy that forwards a port-bearing `Host` and adds no forwarding header is byte-identical to a direct client and stays admitted — call the bundle's port directly from the host or the pod, and block `/_gina/` at the edge in any case and anywhere in the path. False positive: a bundle bound directly to port 80/443 receives a port-less Host from every direct client, so set `server.proxy.requireForwardedHeaders: true` there. Pickup = bundle restart, no re-bake. **#B712 (fixed for 0.7.1) — the handlers #B709 left out match the url's PATH.** `health/check`, `jobs/:id`, `instrument` and the dev/key-gated `inspector`, `logs`, `agent`, `indexes`, `reveal` tested the whole url with no start anchor, so a page whose QUERY ended in an endpoint path (`/web/?next=/_gina/health/check`) got the endpoint instead of the page — inside a maintenance window too, where the page's 503 was due — and a WebSocket upgrade to such a url was taken by the agent. Each matcher now starts `^[^?]*` (the endpoint path must end the url's path) and the dev endpoints' `$` became `(?:\?|$)`; prefix tolerance stays (the Inspector puts the opener page's full pathname before `/_gina/`, and a liveness probe may call the health check under the bundle's webroot), as do letter case, methods and gates. The agent WebSocket upgrade listeners test the query-free path, and the Inspector reads its asset path from it. Residual: `/_gina/assets/routing.json` still matches its path at the end of a query string (`/x?y=/_gina/assets/routing.json` gets the routing map); its handler is reworked separately. **#B707 (fixed for 0.7.1) — a GET of the routing table in another letter case no longer ends an isaac bundle.** isaac's fast path tested the url without regard to case, then looked the asset up by the requested spelling through an exact-match `findOne`: `Routing.json` found nothing and the `localAsset.mime` read threw an uncaughtException that ended the process — one unauthenticated request, before routing, with any path prefix. The looked-up name is now lower-cased. A request classified as proxied was served the host-stripped table and escaped it; the express engine serves the table from memory and was not affected. **#B722 (fixed for 0.7.1) — isaac's own event streams answer HTTP/2 clients.** `/_gina/logs`, `/_gina/agent` and `/_gina/release/events` passed `connection: keep-alive` to node's HTTP/2 `stream.respond()`, which throws `ERR_HTTP2_INVALID_CONNECTION_HEADERS` on any connection-specific header (`connection`, `keep-alive`, `proxy-connection`, `transfer-encoding`, `upgrade`); lib/proc.js logged the throw as a warn and the client never got headers, so an `http/2.0` bundle reached directly over HTTP/2 streamed none of them (0.3.0 through 0.7.0; release events since 0.5.18). The header now goes to HTTP/1.1 only. Reflex: a header set shared by an h1 `writeHead()` and a raw h2 `stream.respond()` must carry no HTTP/1-only header — the compat `setHeader('connection', …)` merely drops it with an `UnsupportedWarning`, while a raw `respond()` throws. Pickup for both: bundle restart, no re-bake. The `/_gina/agent` stream (combined data + log events) is dev-only by default but can be enabled outside dev behind an API key (`settings.json > inspector.agent.{enabled, key}`, `${secret:KEY}`-capable, constant-time compared, fail-closed when no key is configured); browsers pass `?key=` as a query param because `EventSource` and WebSocket handshakes cannot set custom headers — and any `$`-anchored endpoint gate regex must become `(?:\?|$)` the moment its endpoint accepts a query param, or the handler silently stops matching the query'd URL. A time-boxed, separately-keyed production instrumentation window (`POST /_gina/instrument`, hard-capped at one hour) can stream per-request query + flow capture over that authenticated channel — channel AUTH, not redaction, is what protects raw query text (redaction masks only secret-NAMED fields, never the statement or its positional params). The Query tab computes live index coverage client-side, so bundles WITHOUT an `indexes.sql` get a correct "no index for filter" badge on the first render too (cached live-index descriptors are cloned per query before stamping coverage — the cache is shared across queries that filter different columns). The live-index refetch's success re-render lands in `#tree-query` — the container the tab renderer owns and silently bails on when missing — never the scroll wrapper around it (#B222: replacing the wrapper's children destroyed `#tree-query`, so after the first render carrying an `indexes: null` query every later payload — navigation, XHR, the refresh button — froze the Query pane and badge until the Inspector window itself was reloaded; the ⟳ button re-armed the freeze via its forced refetch). Inspector toolbar CSS: native macOS `<select>` ignores `line-height` — use explicit vertical padding, and keep `select` (sans font) and `input` (mono font) on separate CSS rules. **SPA + statusbar hardening (folds former #165/#190/#191/#196):** every per-bundle `/_gina/*` consumer in the SPA derives its base URL via the shared 3-fallback `resolveBundleBase()` (`?target=` → opener pathname → path strip) — a bare `window.location.pathname` strip misroutes to the proxy's default bundle in reverse-proxy multi-bundle setups. A `?target=` value loses its own query string and fragment before `/_gina/…` is appended (`normaliseTarget()`, read by `resolveBundleBase()` and both agent builders; `gina inspector:open` does the same to a positional URL — #B719, fixed for 0.7.1): a pasted page URL (`http://host/page?x=1`) otherwise built `http://host/page?x=1/_gina/agent`, the endpoint path inside the query, which the endpoints ignore since #B712. The Inspector binds to its opener tab via a per-tab `BroadcastChannel` (`?ch=<tabId>`): pages sending COOP `same-origin` sever `window.opener` for the popup, and the bundle-global fallbacks (the shared localStorage slot + the worker-wide agent SSE) reflect whichever render last touched them — a diagnostic channel that must track ONE page needs a per-tab transport (the statusbar publishes that tab's data at its existing write points and answers a request/reply handshake; no `?ch=` keeps the prior behaviour, and `?target=` agent mode keeps priority). Structured-localStorage reads need a SHAPE check on top of the try/catch parse (a tampered key holding a JSON primitive otherwise breaks the un-guarded consumer), and every value interpolated into `innerHTML` goes through an HTML-escape helper — untrusted model/app text renders via `.textContent` instead. Server data serialised into those inline scripts goes through the shared helper `core/controller/inline-script.js` (`safeInlineJson` / `safeInlineString` / `escapeForInlineScript`), applied at every emission site in both renderers (4 swig + 3 nunjucks): `<` → `\u003c` (the earlier literal-`</script>`+`<!--` blocklist was bypassable — per the HTML5 script-data end-tag-name state `</script >`, `</script/>` and `</SCRIPT >` all terminated the block; #B451, 0.6.23), U+2028/U+2029 (JS line terminators that are legal in JSON), and — inside JSON string literals only — `{`/`}` → `\u007b`/`\u007d` (#B463): render-swig splices the `__ginaData`/`__ginaLogs` scripts into the layout BEFORE `swig.compile` (the per-request nonce there is a swig conditional), so unescaped `{{ }}`/`{% %}`/`{# #}` in stored data were evaluated as template source — `A{{ 7*7 }}B` rendered `A49B`, measured on fork 2.0.0/2.4.0/2.8.0; nunjucks splices post-render and was never exposed. Structural braces are never touched (an escape outside a literal is neither JSON nor JS), every value parses back byte-identical; pins: `test/lib/inline-script-xss.test.js` (jsdom, 11 spellings) + `test/lib/inline-script-template-injection.test.js` (renders through the real fork, with a firing control). Both server-side only ⇒ pickup = restart, no re-bake. Dev inline-script splices before `</body>` use FUNCTION replacers: `String.replace(/re/, str)` expands `$`-sequences in a STRING replacement (`` $` ``/`$'`/`$&`), so dynamic content carrying a stray dollar-sequence spliced the whole document into the statusbar `<script>` (SyntaxError → statusbar and launch link vanish) on content-heavy pages while a near-empty smoke page stayed falsely green — any `String.replace(re, dynamicX)` with dynamic content must use a function replacer or escape `$`. Inspector SPA files are copied VERBATIM to dist (no minification) — an inspector-only edit rebuilds only those dist files, never the main bundle artifacts. Bound-window data fidelity (#B225): a preload-consumed popin open sets the dev toolbar's XHR overlay on BOTH consume branches exactly like a cold click (dev-gated `updateToolbar(body)` immediately before each dispatch — in consumePreload, never in the shared dispatcher, which would double-fire the cold path); the statusbar-bound Inspector window's refresh button never reopens the passive `/_gina/agent` stream (`source !== 'broadcast'` on the reopen gate — bound mode takes page-scoped data from the statusbar publisher, and a leaked stream applied bundle-wide payloads over it, blinking the Query badge at every XHR overlay); and every Query-pane re-render (filter/search/show-all plus the live-index refetch success path) derives from the live payload via renderTab('query')'s own preference (reveal swap included) instead of the module query cache, which the empty render path never clears and which survives overlay eras ending while the Query tab is inactive — a refresh no longer resurrects a closed popin's badge count. A `?ch=`-less embedded Inspector (direct URL, bookmark, a window predating bound mode) no longer silently runs those bundle-global channels (#B231): every dev page's statusbar advertises its per-tab channel id in localStorage (`__gina_last_tab_ch`) on each publish, a `?ch=`-less Inspector adopts the most-recently-published tab's channel at boot behind a liveness handshake (the bind's request must be answered by a data frame within 1.5 s — a stale advert from a closed tab tears the bound mode down and falls back to the legacy acquisition, closing the bound-mode `/_gina/logs` stream so log entries are not double-delivered once the passive agent stream attaches), and a footer badge names the active data-source mode (`bound` / `agent` / warn-tinted `global` with a tooltip pointing at the statusbar link) so a degraded bundle-global mode is visible instead of silent. That badge renders from `updateSourceModeBadge()`, which init must call on BOTH acquisition branches (#B306): it previously ran only on the non-agent branch and from `pollData()`, and since the poll timer is deliberately never scheduled under `?target=` (agent data is SSE-pushed), a URL-entered standalone Inspector kept the element's shipped `hidden` attribute and named no mode at all — while the identical `agent` mode DID render when reached through the connect form, whose timer is already ticking when `source` flips. Badging depended on how the window was opened rather than on the mode itself, which is exactly what the badge exists to prevent. The timer stays unscheduled in agent mode on purpose, and a future reader must not "reconcile" that guard to the comment that once claimed otherwise: `pollData()` is NOT a no-op under `source === 'agent'` — it repaints the active tab from cache before returning — so putting it on a timer would repaint every `pollDataMs` and fight scroll position, expanded folds and text selection; the refresh button calls `pollData()` directly and needs no timer at all. The footer memory gauge sits its unfilled track on `--bg3` with a theme-scoped inset groove shadow (#B237) — the track previously shared `.bm-footer`'s own `--bg2` background token, so the empty portion rendered invisible in both themes and a low fill read as a floating green dot; a gauge track must contrast the surface it sits on, and an inset box-shadow paints under the fill child, so the groove shades only the empty region. The Forms tab shows the page's DOM forms enriched with runtime validation state (the statusbar merge writes `u.forms[<id>]` per form), while the whole-bundle forms catalog the server seeds into `user.forms` (`page.forms` = the walked `<bundle>/forms/` directory groups — `rules`, `mocks`, `validators`, open-ended) renders as one collapsed, muted "Bundle catalog" card at the bottom (#B343): catalog keys are recognized by their presence in the pristine `gina.forms` half of the payload (which runtime merges never write), so app-custom group directories demote too, departed runtime forms (a closed popin's form) keep their own card, and a payload with no gina half falls back to the legacy per-key rendering — nothing is ever hidden. **The page whisper excludes `mocks` (#B344, every env):** `page.environment.forms` — the RFC5987-encoded export the client loader parses into `gina.forms` — ships a SHALLOW COPY of the catalog minus the `mocks` group in every environment (dev fixture data has zero client consumers: the client bundle reads `gina.forms.rules` and `gina.forms.validators` only), trimming page weight and keeping the client contract uniform across envs; server-side `conf.forms`/`page.forms` — and therefore this catalog card — keep the full set, and the shared catalog object is never mutated. **The localStorage fallback channel is re-synced by the late-bind patch (#B386, fixed 2026-08-16, shipped in 0.6.11).** `statusbar.html` mirrors `window.__ginaData` into `localStorage.__ginaData` at statusbar-execution time, which is BEFORE the render delegates append their late-bind patch script above `</body>` — and that patch mutates the IN-MEMORY object only. Without a re-sync the mirror keeps the EMIT-TIME payload forever: `metrics.weightBytes` null (null at emit by construction, since the body length is unknown until the render finishes) and the late flow entries absent. Measured live on a dev bundle: mirror `{serverMs:2339, weightBytes:null}` with 17 flow entries against an in-page `{serverMs:2860, weightBytes:628192}` with 21 — the four missing being `swig-compile`, `swig-execute`, `response-write`, `total`. Two visible symptoms, and the asymmetry between them IS the diagnostic: the View tab drops its weight badge (both weight legs falsy) while the load badge survives, because `serverMs` IS set at emit and `weightBytes` is not — so a fallback-channel Inspector shows 2 badges instead of 3, and only until something else re-syncs; and the Flow tab loses its template-compile/execute/response-write/total bars. All three patch sites (render-swig cache-hit and cache-miss, render-nunjucks) now end their patch script by refreshing BOTH channels the Inspector can read, placed AFTER the metrics assignments — ordering is load-bearing, since syncing first would re-mirror the stale payload. **localStorage alone was NOT enough, and that was the first fix's mistake:** it is only the FALLBACK channel, while a statusbar-launched Inspector runs in BOUND mode on a per-tab `BroadcastChannel`. Measured: all SEVEN `_ginaPublish()` call sites sit BEFORE the patch (`publishesAfterPatch: 0`), and a surrogate bound listener held across a real navigation received exactly ONE frame carrying `weightBytes: null` with `serverMs` set — the 2-badges-instead-of-3 symptom, reproduced on the transport itself. The patch now also republishes there, keying the channel from `sessionStorage.__gina_tab_id` (per-tab, race-free) and NEVER from the `__gina_last_tab_ch` localStorage advert, which is last-writer-wins across tabs. The subscriber applies it immediately (`setupBoundChannel`'s `onmessage` → `_bcLatest = payload; pollData()`). ⚠️ The convention `'gina-inspector-' + tabId` now spans FOUR files (statusbar publisher, SPA subscriber, both delegates) and is pinned by a test, since a rename in one strands the Inspector on a channel nobody publishes to — silently, with no error. **Generalisable: verifying the PRODUCER and inferring the CONSUMER is not verification** — the page data measured correct on all four reported URLs while the bug was fully present. The nunjucks site was also converted to a FUNCTION replacer, which both swig sites already used. **Cross-origin WRITE guard (#B384, fixed 2026-08-16 — a CSRF that was live in every release up to 0.6.9).** The admin endpoints authenticate with an AMBIENT credential (the client IP via `lib.admin.isClientAllowed`), which a browser attaches automatically, so an operator browsing from an allowlisted address — loopback by DEFAULT, i.e. the machine running the bundle — could be lured to a page that silently wrote to `/_gina/storage/gc`, `/_gina/cache/clear`, `/_gina/release/rebuild` or `/_gina/maintenance`. The first three read their whole input from the QUERY STRING and no body, so the attack needed no `fetch` and no CORS reasoning at all: an auto-submitting `<form>` sufficed. `lib.admin.isCrossOriginWrite(req)` now fronts the WHOLE family from ONE site per engine, placed above every `/_gina/*` handler so current and future handlers inherit it — the body reader `_readInstrumentBody` was the WRONG seam (it fronts only 2 of the family), and folding the check into `isClientAllowed` would have silently widened a function whose name promises an IP check. Signals, in order: `Sec-Fetch-Site` (browser-computed, a forbidden header name so page script cannot forge it, and independent of a proxy rewriting `Host`) — `same-origin`/`none` pass, `same-site`/`cross-site` refused; else `Origin` vs `:authority`/`Host`, built NEVER from `X-Forwarded-*` (#B367), with `Origin: null` refused (#CSRF3). NO browser signal ⇒ ALLOWED, since curl / the gina CLI / a deploy script carry no ambient credential. SAFE methods (GET/HEAD/OPTIONS/TRACE) are untouched, so the Inspector's deliberately cross-origin GET/SSE channels keep working — measured: the plugin source issues ZERO POSTs to `/_gina/*`, control firing at 23 hits on `/_gina/agent`. `/_gina/instrument` was NOT the worst of the family as first filed: it is key-gated (`_instrumentKeyValid`), so an attacker without the key cannot invoke it; its `access-control-allow-origin: *` is set BEFORE the key check, making only the 401 cross-origin readable. Verified live: 5 attack arms (incl. the query-param form vector and an `X-Forwarded-Host` spoof) all 403 with state never flipping, 5 control arms all allowed incl. a real browser's `no-cors` POST refused where it previously flipped the toggle. Residual, pinned: a browser sending NEITHER signal on a POST would pass — legacy-only, and narrower than the IP allowlist already fronting these endpoints. **#B708 (fixed for 0.7.1) — placement was not enough: the guard's URL test must MATCH WHEREVER A WRITE HANDLER MATCHES.** It tested `/^\/_gina\//`, while the unanchored handlers (`cache/clear`, `maintenance`, `instrument`) test the FULL url — so a leading segment (`/web/_gina/…`, `//_gina/…`) and the endpoint path at the END of the query string (`/?next=/_gina/maintenance`) reached them — and `cache/clear`, `storage/*` and `release/*` match in any case (`/_GINA/storage/gc`). Driven live on isaac (dev and prod): a cross-site `text/plain` POST to each shape got 200 and turned maintenance on (a site-wide 503). The guard now tests `/\/_gina\//i` on the full url, a superset of every write handler on both engines, locked by a source-extraction test that derives the write handlers from each condition's methods (a looser future handler turns it red) and a live boot test. Side effect: a cross-origin browser request with an unsafe method to an app URL merely containing `/_gina/` (query included) is refused too; same-origin and non-browser clients are unaffected. #B679 — the isaac `X-Forwarded-Prefix` handler in this same forwarded-header block ran its trailing-slash trim (`/\/+$/`, which backtracks quadratically on a long run of slashes) on the RAW header BEFORE the length+charset gate, on every request: a 15 KB header cost ~100–170 ms CPU (measured on a booted bundle; the regex alone ~8 s at the 64 KB an HTTP/2 header list admits). The gate now runs BEFORE the trim, so a >255 value is zeroed before any regex sees it; a legitimate ≤255 mount path is unchanged. Reflex: any per-request regex over an attacker-controllable header caps length first, then matches.
|
|
924
924
|
|
|
925
925
|
177. **Couchbase connector — install-derived SDK resolution (v2 removed), `connectors.json` semantics, `getCluster()`, dev-mode index reporting** (replaces individual entries #29, #40, #41, #57, #136, #153) — connectors are keyed in `schema/connectors.json` by LOGICAL name (`primary`, `sessionStore`, `cache`, …) with the driver selected by the `connector` enum field (`couchbase`/`mysql`/`postgresql`/`sqlite`/`redis`/`ai`/…) — never introduce a separate `driver` field; the optional `version` field carries a semver range used by `connector:add --driver-version=…` for the npm-install hint. The Couchbase SDK major is derived from the project's INSTALLED `couchbase` npm version — the leading major of `dependencies.couchbase` selects `connector.v<major>.js`, which stamps `conn.sdk = { version: N }` — never from a config key, so migrating SDK majors is a driver bump (`npm install couchbase@^4`), not a config edit. SDK v2 is REMOVED as of 0.4.0: the resolver now throws a clear "SDK v2 is no longer supported — upgrade couchbase@^3/^4" error when the installed major is ≤ 2 or the connector file is missing (previously a silent fallback that crashed later with an opaque MODULE_NOT_FOUND); the v3-vs-v4 split remains for param shaping. Generalises: when a connector's behavior-version derives from an installed dependency rather than config, fail fast once the installed major drops below the supported floor. Couchbase entities expose a public `getCluster()` (on both the model-entity and N1QL-entity prototypes) returning the underlying SDK `Cluster` handle for features the ORM doesn't wrap — chiefly multi-document ACID transactions (`cluster.transactions().run(...)`, needs SDK 3.2+/4.x) — without touching private `_*` internals; it throws a coded `GINA_COUCHBASE_CLUSTER_UNRESOLVED` error when neither connection shape resolves. Dev-mode index reporting: the SDK v4 C++ binding never populates `meta.profile` despite `profile: 'timings'` being sent (confirmed on v4.6.0), so an async `EXPLAIN <statement>` fallback with a per-process per-statement cache supplies the plan instead (the first request for a new statement may show N/A; subsequent requests hit the cache), and `USE KEYS` plans surface as "KV lookup" via `ExpressionScan`/`KeyScan` operator detection. Three historical traps locked by tests: `conn._cluster.query()` must receive the full `queryOptions` object, not the raw params array (the raw form silently dropped `profile`/`scanConsistency`/`adhoc` from every query); of the connector's two `register()` dispatch paths, Option B (`!_isRegisteredFromProto`) is the ALWAYS-active one — instrumentation or logging added to Option A never executes; and (#B193, 0.6.3) the plan walker must visit the NESTED scan containers, not just `~child`/`~children` — the multi-index operators (`IntersectScan`/`UnionScan`/`OrderedIntersectScan`) put their child `IndexScan3` nodes under a `scans` array and `DistinctScan` under a singular `scan`, so before the fix any plan the planner served with more than one index extracted `[]` and the Inspector rendered the red "no index — full bucket scan" badge/banner for a fully-indexed query — a false negative inviting a pointless (write-amplifying) index build. Both extraction paths (SDK `meta.profile` and the EXPLAIN fallback) share the walker, so one fix covers both; index names still dedupe, and the Query tab already renders one chip per index so multi-index plans display correctly with no client change. A consumer-runnable SDK soak harness ships at `script/soak/couchbase-soak.js` (#CN12): it scaffolds a fully isolated throwaway project, installs a candidate `couchbase` SDK into it (`--sdk=<version>` / `--sdk-path=<dir>` — the install IS the version selector, since the connector resolves the SDK from the project node_modules and derives v3/v4 dispatch from its dependency pin), builds + boots prod, and drives N1QL (incl. a `request_plus` arm) + entity-handle KV (promise AND 4-arg callback forms) + the couchbase session store under sustained concurrent load for `--duration`, FAILING on premature process exit (a clean exit 0 counts as failure — the silent-death class it screens for), unbounded RSS growth, error-rate drift, or a dead arm. A screen, not proof: run it as the first filter on an SDK-bump candidate, ahead of a workload-shaped soak. Pure parts (arg parsing, RSS slope, verdict) are unit-tested in `test/lib/couchbase-soak-evaluator.test.js`; the live harness needs a real cluster and never runs in CI. Named scopes/collections stance (2026-08-02): document-field partitioning (`_scope`/`_collection` fields, one bucket, default collection) IS the data model; the `useScopeAndCollections` option (+ `scope`/`collection` defaults) is accepted but INERT — declared and merged, consumed by nothing — and `schema/connectors.json` says so honestly; named-collection KV is reachable per call via `entity.getConnection(scope, collection)`; native scope/collection routing is deliberately not built (demand-gated). #B203 (2026-08-02): both SDK-major resolver twins derive the major as the dependency pin's FIRST integer — the former caret-only strip mangled range pins (`~4.5.0` → `~4`, `>=4.5` → `>=4`), which slipped the v2 floor (parseInt NaN) into a misdirecting existsSync "supported majors are 3 and 4" error; a digit-less pin (`*`, `latest`) now refuses naming the pin, a package.json without a `dependencies` key no longer TypeErrors, and the v2 floor fires for range v2 pins too (`~2.5.0`). #B243 (2026-08-04): a query parameter the SDK cannot serialize — a bare `undefined`, a function, or a Symbol — was PROCESS-FATAL rather than throwable: the SDK maps `JSON.stringify` over the parameter list, those three types yield no string at all, the native binding coerces that to `""`, and the C++ core's JSON parse of `""` throws on an internal thread reaching `std::terminate()`/`abort()` — uncatchable by `try/catch`, `uncaughtException` or `unhandledRejection`, so the whole bundle died instead of the request 500-ing (measured against a live cluster on SDK 4.1.3 AND 4.7.1; 4.2.0+ maps a bare `undefined` to null but still aborts on functions and Symbols). Reachable with NO misuse of the driver: the cursor-style assembly branch (queries matching `\w+\.($|%)`) fills `queryParams[i]` for every `i < params.length` while guarding only `undefined`, so a call one argument SHORT with a trailing callback puts the CALLBACK into a parameter slot — the arity check cannot catch that shape because it only fires when the last argument is not a function. `getUnserializableParamError()` now gates the single `queryOptions.parameters` assignment (so both assembly branches and any future one are covered) and surfaces a `TypeError` coded `GINA_COUCHBASE_UNSERIALIZABLE_PARAM` naming the offending position, routed through the query callback when there is one and thrown otherwise. Serializable values are untouched: `null`, `0`, `''`, `false` and objects carrying `undefined` PROPERTIES still reach the SDK. An object whose own `toJSON()` returns undefined is deliberately left unguarded — detecting it costs a full `JSON.stringify` per parameter on every query for a shape no realistic call site produces. **`.sql` `@options` annotation — the exact contract, and the consistency gate that silently dropped keys (former #282, #B155; warns since 0.5.26):** the parser accepts ONLY `@options { … }` — a brace-delimited JS-object-literal after exactly ONE space (unquoted keys fine: a key-quoting normalisation runs before `JSON.parse`; a malformed body warns and is skipped). The historical docs-taught brace-less form (`@options consistency=request_plus`) and a double-space-before-brace both MISS the parse regex entirely. The passthrough into the SDK's query options is GATED on the `consistency` key: any other key (`adhoc`, `timeout`, `profile`, …) applies ONLY when `consistency` is present alongside it — alone it is parsed then dropped. Since 0.5.26 BOTH failure shapes warn (was silent): an unparseable `@options` mention warns with the exact working form; a gate-shut drop warns naming the ignored keys + the `"consistency": "not_bounded"` remedy; an empty `@options {}` drops nothing and stays quiet. An UNKNOWN consistency VALUE warns and falls back to `not_bounded` but STILL opens the gate — the gate keys on presence, not validity. Query-path defaults: `adhoc: false` (statement plans cached — `true` disables that) + `not_bounded`; user keys WIN over framework values (direct assignment — including dev-mode's `profile: 'timings'`). `bulkInsert` is a SEPARATE surface: a JS `options` argument, UNGATED, merged caller-wins-on-conflict / defaults-fill-missing (its default is `adhoc: true`). The gate itself deliberately stays: un-gating would make historically-inert options suddenly live for every consumer, and a bare un-gate CRASHES (the next statement dereferences `options.consistency`) — a restructure gated on field evidence, which the warns now collect. Tests: `test/core/couchbase-connector.test.js §09` drives the SHIPPED parse+gate bytes (unique-text-anchored slices — line numbers rot and the parse block's regex holds an unbalanced `{` that defeats brace-matching). **#B204 (0.6.24) — the `gina.onError` reconnect classifier is guarded; it used to THROW under every supported SDK.** `lib/connector.v3.js`/`lib/connector.v4.js` tested `err instanceof couchbase.Error`, but no supported couchnode exports a bare `Error` class (measured on a real 4.1.3 `dist/errors.js`: `Error` undefined, `CouchbaseError` + 81 siblings present, and the classifier condition executed against that surface throws `TypeError: Right-hand side of 'instanceof' is not an object`). Because that operand is evaluated FIRST, the shutdown-bucket message arm — which needs no SDK class — was unreachable too, so the classifier could not classify anything by any route. **Dispatch topology, measured:** `gina.onError` listeners fire ONLY under 4-arg (Express-engine) error-middleware invocation; Isaac's dispatchers invoke middlewares 3-arg, so the arity shim sets `error = false` and the emit branch never runs — on Isaac the listener, broken or fixed, never executes. **Fix = guard, deliberately NOT an activation:** each `instanceof` is prefixed `typeof(couchbase.Error) == 'function' &&` (4 live sites, both files). Modern SDK errors carry no numeric `.code`, so the guarded arms stay inert; the SDK-2 message string greps 0 in the 4.1.3 dist JS (`timeout` firing as control); on Express the real error now reaches the handler's designed terminal (log + JSON 500 / `next(err)`) instead of being replaced by the TypeError. ⚠️ **Do NOT "modernize" this into an active reconnect without a live-cluster gate:** gina's own `connect()` mints a NEW cluster handle per call (`couchbase.connect` at `connector.v4.js:265`) and the only `disconnect()` sits inside the code-23 arm — an activated retry loop would stack unclosed handles. Also do NOT remove the listener: `e` (`gna.js:81`) is a plain EventEmitter, and an `'error'` emit with no listener THROWS (measured), so the registration is load-bearing on Express. Tests: `§16` (both files' real condition bytes extracted and driven — no-`Error` SDK must not throw and the message arm must be evaluable, with an SDK-2-shape control green pre- and post-fix; red-first 3 red / 2 green). **#B509 (0.6.29) — a failed statement's RESULT ROWS no longer ride the synthesized query error.** Both onError sites (`register()` and `bulkInsert`) build `new Error(cause.first_error_message)` from the N1QL `cause` envelope and used to set `error.stack = trigger + '\n' + cause.http_body` and `error.cause = cause` VERBATIM — and `http_body` is the query service's WHOLE response, whose `results` is non-empty whenever the failing statement had already produced rows (a `RETURNING` DML losing a CAS race, 12009; a SELECT timing out part-way, 1080). So the rows reached every sink that printed the error: the controller error path prints `.stack` in ALL scopes; any `console.error(err)` / `logger.error(err)` prints enumerable `cause` through `util.inspect` (the logger's `inspectError` IS `util.inspect`; Node's default `maxStringLength` lets 10,000 chars of the body through); the JSON error response carries `.stack` in LOCAL scope only (stripped elsewhere, #B131 on both engines); a custom error template receives `stack` ungated. NOT sinks: the Inspector query log (`error: err.message` only) and `lib/connector-error` (reads exactly `first_error_code` + `retry`). A consumer measured 17 of 18 logged query errors in 16 h carrying 1–7 full documents each, with no interception point (the controller prints the stack before any bundle `onError` runs). Fix: `param-redact.redactResultRows(httpBody)` — the #B350 module, same fail-safe contract on the OUTPUT side — parses the envelope, replaces `results` with `[N result rows redacted]`, keeps `errors`/`requestID`/`status`/`signature`/metrics, and FAILS CLOSED (a non-string or unparseable body becomes a byte-count marker, never the raw text; it never throws); both sites set `.stack` from the redacted body and `error.cause = Object.assign({}, cause, { http_body: redacted })` — a shallow copy, so the SDK's object is never mutated and the classifier's two inputs survive. The #B153 empty-message guard is untouched. Consumer-visible: nothing reads rows out of a query error's stack any more. Server-side, load-once ⇒ bundle RESTART, no re-bake. Tests: `test/core/couchbase-b509.test.js` (helper arms incl. fail-closed and a `__proto__`-key body; both-site source pins with an anti-vacuity control; a replica proving every channel clean, with the pre-fix branch as the subtract control); `couchbase-connector.test.js §08`'s fixed replica realigned. Generalises: an error object that carries a foreign payload leaks through EVERY generic printer, not just the one you instrumented — redact at the point the payload is attached, and copy foreign objects rather than mutate them. **#B541 (0.6.31) — a connector that cannot CONNECT at boot fails loudly instead of hanging, and one failed attempt reports once instead of twice.** Consumers wait on a ONE-SHOT `ready` (`onReady` registers `self.once('ready', cb)`), and every failure path in `connect()` routes to `onError`, which only re-arms a retry — it never emits. So an unreachable cluster at boot settled nothing: `lib/model.js`'s all-or-nothing ready gate never closed, the bundle never printed the two flags `bundle:start` waits on, and the CLI killed it at ~64s (`lib/cmd/bundle/start.js`: maxRetry 15 × maxTimeout 4000ms) with no error anywhere. The loud path — `console.error` + `process.exit(1)` in `onModelReady` — was already correct and is how every OTHER connector reports a dead database (the other eight invoke the caller's callback on every path, failure included); it was simply unreachable here. Fix, both files: `init()` arms a readiness deadline and, if nothing has settled by it, emits `ready(err, null)` itself so that path runs. ⚠️ Do NOT "fix" this by un-commenting the disabled `self.emit('ready', bErr, null)` — the same 2023 hunk that commented it ADDED the retry, so un-commenting restores the loud path and destroys the retry. Tunable per `connectors.json` entry via `readyTimeout` (MILLISECONDS, default 50000, in `schema/connectors.json`); a value at or above the ~64s start budget cannot take effect because the CLI terminates first, and a non-positive/non-numeric one falls back to the default. Two deliberate shapes: the deadline is NOT unref'd (were it the only handle holding the event loop, unref would let node exit 0 silently instead of reporting), and `_markSettled()` only records the settle + disarms the timer — it does NOT gate the emit, because `core/model/index.js` attaches a persistent `.on('ready')` bridging reconnect churn onto the Inspector event signal while both `onReady` consumers use `once`, which already drops a late delivery. Retry is untouched: uncapped after a settle, so a serving bundle still survives a blip. **Same arc, the double report:** on failure the SDK settles BOTH channels — the `onBucketOpened` callback AND the awaited promise — so one failed attempt called `onError` twice, arming TWO retry chains that each armed two more (2→4→8…) and double-counting `_reconnectAttempts` so the backoff hit its 60s cap after five real attempts instead of ten; `onError` now goes through `settleOnce` minted PER `connect()` invocation (driven: counter 2 → 1). That guard also fixes a shape it was masking — `onError` dereferences `self.instance`, which is never initialised at construction and is assigned only by the callback channel, so an SDK rejection that never calls back THREW at `onError`'s first statement, arming no retry and emitting nothing, visible only as an unhandled rejection of the bare `self.connect(dbString)` (driven: counter `undefined` → 1) — a silent death, which is why it went unnoticed. v3 matters as much as v4: it is the DEFAULT when a project pins no `couchbase` dependency. Server-side, load-once ⇒ bundle RESTART, no re-bake. Tests: `test/core/couchbase-boot-deadline.test.js` (boots the REAL connector against a planted project-side SDK stub — `connector.v*.js` requires the SDK at `getPath('project') + '/node_modules/couchbase'`, so seeding the project dir IS the seam; mocked timers, because `onError` does not retain its retry handle and a real chain would re-arm forever and hang the file; a SUCCESS arm as the firing control, since `init()` reads `getConfig()` inside a try whose catch emits `ready(err)` and an unwired harness therefore makes the FAIL arm settle and read as no-defect). Generalises: a connector that reports readiness by EVENT rather than by invoking the caller's callback has no failure path unless one is built — an error-only retry loop is indistinguishable, from the gate's side, from a connector still trying. **#B608 (0.6.33) — no caller or configuration value reaches N1QL statement TEXT unvalidated.** Three splice sites, three fixes. (1) `SEARCH()`: the 2021 block matched `(search\(|search\s+\().*\)` — greedy, so the span ran to the statement's LAST `)` — and rewrote every `$N` inside it to `'"' + value + '"'` through a STRING replacement: no escaping (a search term carrying `"` made the statement malformed), `$&` expanded, `$1` also rewrote `$10`, value params that merely followed the call were re-typed as string literals, and every distinct term compiled its own prepared plan (`adhoc:false`). Its premise ("N1QL parameters are not interpolated into SEARCH()") was wrong: Couchbase documents a parameter as SEARCH()'s query argument when it resolves to a string or an object (docs 6.5 → 8.0; the engine's `ValidOperands()` accepts a static parameter since 6.5.1, source-read), and it was measured on Server 8.0 CE — ad-hoc AND prepared, a complete search-request object with the parameter nested inside, a `"`-carrying term bound without error, the plan still `IndexFtsSearch`. The block is commented out and `$N` stays bound; behaviour changes: a non-string term is no longer stringified, a value param after SEARCH() keeps its type, one prepared plan per statement. (2) Field-path `$N` (`doc.flags.$2`, which cannot be bound): `getInvalidFieldPathError()` admits only `identifier(.identifier)*` (`[A-Za-z_][A-Za-z0-9_]*`, `$` excluded) and the call is refused BEFORE dispatch with a `TypeError` coded `GINA_COUCHBASE_INVALID_FIELD_PATH`, delivered like #B243 (callback when there is one, else thrown — the Promise form throws synchronously). (3) `$scope`: `resolveScope(infos.scope, process.env.NODE_SCOPE)` resolves it ONCE at factory load (same `||` precedence; the factory runs again when a couchbase reconnect rebuilds the models through `reloadModels`, which passes the entry's `scope` since #B624 — the rebuild used to omit it and fall back to `NODE_SCOPE`) against `^[A-Za-z0-9_./-]+$` (`/` stays admitted so that a scope registered under the retired `scope:add <bundle>/<scope>` form keeps booting; since #B626 `scope:add` checks the whole name and refuses `/`, pointing at the `manifest.json` `bundles.<name>.scopes` allow-list, the real per-bundle mechanism), stamps both prototypes with it and feeds `/\$scope\b/g` through a function replacer; a refusal ends the boot through the EXPLICIT terminal (`console.emerg` → `fs.writeSync(2)` → `process.exit(1)`, placed just before `return init()` so every factory-level `var x = function` is assigned) — never a bare throw: the factory runs inside the model layer's ready handler, which this connector reaches from inside the SDK's connect callback, and the SDK 4.x hands a throw from that callback back to the same callback as a connection error — logged as a failure to connect and retried, the bundle never listening (read from the SDK 4.1.3 source). Since #B617 `lib/model.js`'s `done()` catches such a throw too; the explicit terminal keeps the refusal's own message and code. Server-side, load-once ⇒ bundle RESTART, no re-bake. Tests: `test/core/couchbase-b608.test.js` — both helpers extracted from the shipped bytes and executed; the REAL connector against a recording cluster stub; the boot terminal in a CHILD process (a `process.exit(1)` in the runner takes the whole file down); comment-stripped source pins with raw-text anti-vacuity checks; the env seam `GINA_COUCHBASE_CONNECTOR_SRC` runs the whole file against pre-fix bytes (8 controls green, 32 fix arms red). Generalises: a comment asserting "the driver cannot bind X" is a claim to re-measure against the vendor docs and a live server before it may justify a splice. **#B616 (0.6.33) — the fourth splice site #B608 missed:** `bulkInsert` wrote the bucket name (the entry's `database`) bare into `INSERT INTO <bucket> (KEY, VALUE)` and `RETURNING <bucket>.*`, so a legal dashed bucket such as `beer-sample` (N1QL needs it escaped) could not be bulk-inserted into. One keyspace string, backtick-quoted with any embedded backtick doubled, now serves both clauses and the redacted log copy; no load-time refusal was added, since Couchbase bucket names may contain `.` and `%`, which the storage store's `IDENTIFIER_RE` excludes. The statement text changes for every bucket; the query means the same. Load-once ⇒ bundle RESTART. Pin: the self-contained `#B616` arm in `test/core/couchbase-concurrency.test.js` §06 (the real factory on a `beer-sample` bucket). The REST query transport is RETIRED (#B634, #B623): `useRestApi: true` used to route every N1QL query through a hand-written plain-http `http.request()` to the query service — `Authorization: Basic` in cleartext even on `couchbases://`, every `'` in the statement rewritten to `"`, parameters spliced in unescaped — so the option is now ignored with ONE warning per connector, and queries always go through the SDK (`conn._cluster.query`).
|
|
926
926
|
|
|
@@ -940,7 +940,7 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
940
940
|
|
|
941
941
|
211. **Request-body parsing contract — verbatim JSON, `req.rawBody`, and crash-safe decodes (consolidates former #162/#163/#164; #B28/#B30/#B64/#B588–#B592).** `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; error disposition unchanged — POST/PATCH 500 only when both attempts fail, PUT warns). **Urlencoded and unlabelled bodies follow the standard form algorithm (#B588/#B589/#B592, 0.6.33).** The POST/PUT/PATCH site hands the body over VERBATIM (only the content-type-gated `+`→space and a leading-`?` strip run there) and `formatDataFromString` splits on `&`, splits each pair at its FIRST `=`, then percent-decodes the name and the value exactly ONCE. Before, the body was decoded as a WHOLE — at the site and again in the helper — BEFORE that split, so an encoded `&`/`=` inside ONE value OR name became a separator that could ADD or OVERRIDE any other field (`role=user&bio=hi%26role%3Dadmin` → `role=admin`; a NAME carrying `%26role%3Dadmin` did the same), a raw `=` cut a value short (`a=b=c` → `b`), and a value was decoded up to four times (`100%2525` → `100%`). Now: a value leaning `{`/`[` is parsed as JSON when it parses — JSON carries its own types, so a quoted `"true"` INSIDE it stays a string (it was coerced) — and kept verbatim when it does not (it was dropped); a JSON value under a bracket key nests like any other value; bare `true`/`false`/`on`/`null` stay strings (they always did); raw quotes are kept (`Turn it "on"` no longer becomes `Turn it true`); a value-less segment is dropped and a repeated key keeps the last value (unchanged). The `"true"`/`"false"`/`"on"`/`"null"` casting is a DOCUMENT feature: it runs only when the whole input is a `{`/`[`-leading document or an object (stringified first — the browser validator's contract). A document is NEVER percent-decoded (#B589): the GET/HEAD re-parse used to decode the whole serialized query, so a value whose TEXT held `%22`/`%0A`/`%5C` made the document invalid and dropped EVERY query parameter (`req.get = {}`, and a `validator::` requirement copying that data answered 404); a fully `%7B`/`%5B`-encoded document is still decoded once. Isaac's query parser now decodes the NAME once as well (`+`→space first, as express's `qs` always did), so an HTML-form `user%5Bname%5D` still nests without the round-trip's accidental decode. A top-level `__proto__`/`constructor`/`prototype` name is dropped on both paths (#B592 — a JSON-valued `__proto__` swapped the PROTOTYPE of the parsed object); a route declaring a DTO re-parses its validated payload through this helper, so a top-level `prototype` key in its JSON body is now dropped too, while a plain `application/json` body is parsed as-is and keeps it. Parse failures log metadata only, never the input and never `err.message` (a `JSON.parse` message can quote the input — V8 and JavaScriptCore both do): the helper's `[365]` line logs the input's length, its leading character and `err.name`, and isaac's not-JSON warn the parameter's name, the value's length and `err.name` (#B590). The helper ships in the browser bundle, so pickup is a restart AND a re-bake. Suites: `test/lib/request-parsing-b588.test.js`, `test/lib/request-parsing-logs-b590.test.js`, `test/integration/container-boot-request-parsing.test.js` (a real boot, red-first against the pre-fix tree). **XML bodies pass through VERBATIM (#FIN1).** `application/xml`, `text/xml` and the `application/*+xml` suffix family fork off the legacy path on POST/PUT/PATCH before any decode: `request.body` stays the exact document (the same string `rawBody` carries) and the method slot is left unset — the routing loop normalises it to `{}` further down, so a controller still reads an object from `req.post`. The framework never PARSES XML, so it takes on no XML-parser surface and no XXE exposure; consumers bring their own library. Pre-#FIN1 such a body was silently DESTROYED rather than rejected: the bracket-notation data helper splits on `&` then on `=` and keeps only the first pair, so an XML declaration alone collapsed a whole document to a single bogus key which — having a key — then REPLACED `request.body` at the shared tail, while the `"true"`/`"on"` coercion separately rewrote quoted attribute values. The matched set is the convergent one — ASP.NET Core registers ApplicationXml/TextXml/ApplicationAnyXmlSyntax (`application/*+xml`), Spring's `MimeType.includes()` documents `application/*+xml` as including `application/soap+xml`, and type-is matches structured suffixes generically — with the wildcard scoped to `application/` in all three, so `image/svg+xml` is deliberately NOT matched and RFC 7303's non-document types (`application/xml-dtd` §9.5, both `xml-external-parsed-entity` §9.3/§9.4) are excluded. RFC 7303 §9.6.1 registers the suffix but RFC 6838 §4.2.8 makes it a naming convention that does NOT mandate generic processing, so this set is convergent implementation practice, not a normative requirement. ONE flag computed at the request prologue feeds all three branches (the #B103 single-site lesson, after those same three sites drifted); each arm seeds `obj = {}` because the shared POST/PATCH tail reads `typeof(obj) == 'object' && ownCount(obj)` and `typeof null === 'object'`. Both engines share the chokepoint; server-side only, no dist rebuild. **#B546 (0.6.31) — that tail, and every other own-property count over a CLIENT-KEYED container, goes through a module-local `ownCount()` rather than `<container>.count()`.** gina installs `count()` on `Object.prototype`, so the shorthand is an ordinary property lookup that an OWN field of that name SHADOWS, and a request carrying a top-level `count` made the framework call a string: a 500 on the guarded body branches, and an uncaughtException that EXITED the bundle process on the UNGUARDED query ones — unauthenticated, one request, on ANY url including one matching no route, because this parse precedes routing (driven live both ways: every shape reproduced before the fix and answers cleanly after). `ownCount()` is that same helper reached as `Object.prototype.count.call()`, which no own property can shadow, plus an explicit null/undefined branch that PRESERVES the throw the `obj = {}` seeding above depends on — the bare `.call(null)` would bind `this` to the global object and return ITS key count (measured 23) instead of throwing. Nested keys and near-misses (`counter`) were never affected. 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 decodes once, guarded — and since #B589 the data helper never percent-decodes the serialized query document at all), 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`. **GET/HEAD query serialization no longer destroys escapes (#B407, consumer-reported):** both branches round-trip `request.query` through JSON.stringify + a re-parse, and the former textual nested-JSON unwrap ended with a blanket backslash strip over the WHOLE serialized document — so `%0A` in a value became the letter `n`, a backslash vanished, and a double quote OR a JSON-ARRAY value produced invalid JSON that silently dropped EVERY query param on the request (`parseBody` logs `[365]` and returns undefined). Both sites now serialize via `serializeQueryForReparse` (a shared inner helper in `processRequestData`): each own STRING value leaning `{`/`[` is unwrapped by PARSING it (kept verbatim when it does not parse — previously that shape also dropped the whole query), then a clean stringify with no post-processing; the whole-value `"true"/"false"/"on"/"null"` coercion is unchanged and, with escapes intact, can no longer match an escaped occurrence embedded inside a longer value. Intended behaviours preserved byte-for-byte (nested-object unwrap — now also correct for nested content carrying escapes, which the textual unwrap corrupted as well — coercion, plain values); array-valued and unparsable-`{`-leading params are delivered instead of killing the query. **PROTOTYPE-POLLUTION GUARD (#B446).** Every parse path that nests a bracket-notation key routes through `parseLocalObj` in `helpers/data` (aliased `nestBracketNotationKey`, and reached from `formatDataFromString`), which assigned `obj[key[k]]` from a CLIENT-SUPPLIED field name with no key filtering: `__proto__[x]=y`, `constructor[prototype][x]=y` and the percent-encoded `%5F%5Fproto%5F%5F[x]=y` each wrote to `Object.prototype` process-wide while the parsed body still read `{}` — i.e. SILENTLY. Reachable from the GET query string, urlencoded bodies, `inheritedData`, and multipart text-field names. ✅ **CONFIRMED LIVE over real HTTP on the DEFAULT `isaac` engine** (daemonless throwaway bundle, pre-fix worktree vs fixed tree, same fixture): a single unauthenticated `GET /?__proto__[polluted]=OWNED` — no body, no interaction — returned `polluted:"OWNED"` with `hasOwn:false` (the prototype-pollution signature, not an own property) and `arrayToo:"OWNED"`, and **the NEXT, innocent request inherited it at the same pid**, proving cross-request persistence. All three vectors reproduced; the fixed tree answered clean on all three with benign bracket nesting still HTTP 200. ⚠️ Isaac does NOT reference these functions at all — its own query parser is flat (`request.query[a[0]] = a[1]`); the attack survives because `server.js` re-serialises isaac's flat query (`serializeQueryForReparse`) and re-parses it through `formatDataFromString`, which restores the bracket branch. ⚠️ Instrument trap that nearly produced a FALSE NEGATIVE: `curl` treats `[...]` as a URL glob range, so every bracket request fails inside curl and never reaches the server — use `curl -g`. A second, independent source: PUT merges `JSON.parse` output as the merge SOURCE, and `JSON.parse` produces an OWN `__proto__` key, so an own-property check alone does NOT stop it — see #321. Measured gadgets before the fix: outbound-request options fully controllable (host/hostname/port/method/auth, `rejectUnauthorized` forced false, `protocol` feeding a dynamic transport require), `SAFE_HTTP_METHODS[m] === true` classifying POST as safe, and `req.routing.csrfExempt` reading true — the last two ONLY via the JSON source, since the string source yields `"true"` and is correctly rejected by the strict compare. ⚠️ The `csrfExempt` one is UNRESOLVED, not confirmed on a live route: what was measured is that a routing object LACKING an own `csrfExempt` reads the polluted value, but `core/server.js:7674` builds routes as `csrfExempt: routing[name].csrfExempt || false`, creating an OWN key that shadows the prototype — while `lib/routing/src/main.js:220` sets it only when already defined, so a route built there can lack it. Which construction path a live `req.routing` takes was NOT determined. Both sources now reject `__proto__` / `constructor` / `prototype` path segments and DROP the field rather than throwing (a throw on the parse path would turn one bad field into a 500). The guard is kept INLINE in `parseLocalObj`, not a closure helper, because `test/core/validator-send-formdata-nesting.test.js` extracts that function's source text and evals it standalone to pin client/server parity — a helper call is a ReferenceError there. `helpers/data` and the validator copy are both browser-bundled, so pickup is a bundle restart AND a re-bake. **#B591 (0.6.33):** `parseLocalObj` also carried a dead `obj = []` rebind for a numeric NON-LAST segment under a non-array container — the parent never saw it and the child slot it seeded was `null` — so `0[a]=1` threw a TypeError, and from the unguarded GET/HEAD `inheritedData` parse that EXITED the bundle (one unauthenticated request; measured exit 143). The rebind is gone in both copies: such a segment now creates a plain `{}` slot (`0[a]=1` → `{"0":{"a":"1"}}`), and no previously non-throwing result changed (every path through the rebind threw). Suite: `test/lib/bracket-nesting-b591.test.js`. Regression suite: `test/lib/prototype-pollution-b446.test.js` (attack arms each paired with a benign control, pre/post A/B validated on extracted HEAD source).
|
|
942
942
|
|
|
943
|
-
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`. **The contextless `require('gina')` boundary + MQ-speaker transport resilience (moved here from the DTO entry that surfaced it; #B276/#B277 0.6.4, #B318 0.6.5, #B323 fixed post-0.6.5 — shipped 0.6.6 with the reconnect).** Importing gina outside a spawned bundle child has ALWAYS been an intended boundary (`index.mjs`'s own docblock states it; both published entries throw alike), but two defects sat on top of it. **(1) The throw was uncatchable and could HANG the process.** The logger is a load-time singleton whose 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 (listener-CONTINGENT, measured both ways: nothing bound to the MQ port ⇒ ECONNREFUSED ⇒ 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: a THIRD state exists beyond connected/REFUSED — an UNREACHABLE host, where `unref()` covers the socket HANDLE but not the pending `TCPConnectWrap` REQUEST, which holds the loop by itself for the OS connect timeout (~75s on macOS); fixed with an unref'd connect deadline (2s default, `opt.mqConnectTimeout`) that `destroy(err)`s only while `client.connecting`. ⚠️ **That safety claim — "only while connecting" — is MEASURED FALSE and shipped a HIGH regression for one release (#B323):** `client.connecting` does NOT flip when the kernel completes the connect; it flips when the POLL phase runs `afterConnect` — and a timer fires in the TIMERS phase, which precedes poll. On any boot that blocks the event loop past the deadline (a bundle mount off a network filesystem — 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 (the speaker dialled exactly once, ever). Measured both directions: 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. The fix defers the verdict one phase — `setImmediate` INSIDE the deadline, so the read happens in the CHECK phase, after poll — a completed connect survives while a genuinely pending one still dies on schedule (#B318's no-hang contract intact). **Generalisable: 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; a timers-phase guard over I/O state wants a `setImmediate` (or the event itself), never a direct read. **The speaker also RECONNECTS (0.6.6):** `startMQSpeaker` returns a stable `{write}` transport FACADE (`mq/index.js` captures the return ONCE for the life of the process — without the indirection a replaced socket is 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 — a shared object would make every reconnect skip its acknowledgement); `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 BOTH ways: a short-lived CLI still exits while a long-lived bundle keeps retrying and HEALS if a listener appears later. The caller callback settles ONCE, the warn fires once per OUTAGE (not per retry), 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. ⚠️ The socket still CONNECTS — `unref` changes only whether it votes on process lifetime (consumer-verified against a live listener: the connected line still logs; "unref'd" is not "suppressed"). ⚠️ The workaround `GINA_LOG_STDOUT=true` does splice `mq` out of the flows but 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 boundary's error now names the boundary** — the old message named an internal call (`setPath("gina.home", path): path cannot be empty`) because `getEnvVar` reads `process.gina` ONLY and is empty outside the CLI; it now routes the reader to `SuperController.createTestInstance()`, the supported way to exercise controller code without booting. ⛔ **Do NOT "fix" the boundary by extending the `getEnvVar → process.env` ladder to `gna.js`, nor by skipping the empty `setPath`** — REFUTED BY MEASUREMENT: satisfying that call merely defers the failure 8 lines to a bare-global `ReferenceError` (strictly less legible), and that `setPath` is the SOLE registration site for `gina.home` repo-wide (the SQLite connector and session store read it). Failing fast at the first detectable point is correct. Diagnostic instrument for the hang class: `process._getActiveRequests()` reports `TCPConnectWrap` while `_getActiveHandles()` shows only the unref'd Socket. Tests: `test/lib/logger-mq-speaker-resilience.test.js` (§01 deferral-ordering + reconnect-shape pins; §02 a child that blocks 3s right after the dial and must still deliver — red-first: pre-fix it lost the frame while the connection was ESTABLISHED, the defect's own signature; §03 a listener destroyed and rebound must be heard again; §04 the no-listener child still exits promptly) + `logger-mq-speaker-unref.test.js` (comment-stripped source pin — subtracting the fix left a bare existence pin GREEN against the commented-out line — + a behavioural arm with its own must-be-killed control). **Log redaction (#B433, 0.6.19):** every message is redacted BEFORE it is rendered and dispatched — inside the logger's `emit()`, the single pre-render point all 46 URL-bearing framework sites pass through (both engines, every render delegate, the CSRF filter; all levelled, none `console.log`), plus the raw `console.log` path — so stdout, the MQ speaker/`gina tail`, the file transport and the Inspector taps all receive the same masked line and a JSON line cannot be corrupted (the marker lands inside the message string). Configured per bundle in `settings.json > log.redact` `{enabled, defaults, secrets, patterns}`, ON by default: `defaults` = JWT · URL userinfo password · `Bearer`/`Basic` · named credential query keys (`token`, `access_token`, `api_key`, `secret`, `password`, `signature`, `otp`, …, key kept / value masked) · api-key headers — measured 0 false positives on 433k real log lines, ~1 µs per line, linear patterns; `secrets` = every value the secrets resolver substituted for a `${secret:KEY}` placeholder (settings, connectors, routing — one resolve point) masked verbatim, values < 8 chars skipped with a boot warn; `patterns` = consumer rules (regex source string → `[REDACTED]`, or `{pattern, flags, replacement, name}` with `$1` kept), `g` always enforced. Deliberately NOT a default: a bare long-hex path segment (a sha256 content-address key from storage and an opaque 64-hex credential are the same regex class — the consumer adds `(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])` — anchored on the class, not `\b`, which never fires against a prefixed `key_<hex>` segment). Fail-closed: an unknown key, a non-boolean flag, an invalid or empty-matching pattern refuses the boot (a dropped rule is a leak). Installed by `core/config.js::loadBundleConfig` right after the bundle's `secrets.resolve()` — the earliest resolved point, so connector-init lines are already covered — via `console.setRedaction(block, {group, secrets: lib.secrets.getResolvedValues(conf)})`; state lives on the logger's persisted context (survives `refreshCore()`), keyed by bundle, UNION across bundles (one bundle enabling is enough in a merged process; a per-route `logUrl:false` flag was rejected by measurement — it cannot reach the second URL copy inside the #ERRREF error stack). The 404 ref line carries the URL twice in ONE record (prefix + the error's own message); both copies are masked. Restart to apply, no re-bake (server-side only). A per-route or per-site flag is NOT the extension point; the logger seam is. Error rendering (#B434, 0.6.19): an `Error` passed as a log argument renders via `util.inspect` — message, stack, own props, `[cause]` chain — at BOTH stdout writers (the levelled `write()`/`parse()` walk and the raw `console.log` `JSON.stringify` path each saw only ENUMERABLE own props, so a bare Error rendered `{}`), top-level and nested alike, realm-safe (`util.types.isNativeError` beside `instanceof`), and the rendered message still crosses the redaction seam (content is built in `write()`, redacted in `emit()`). Non-Error arguments render byte-identically to before. **The `file` log sink — an IN-PROCESS transport, and the three defects that made it unusable (#B523/#B526–#B531, 0.6.30).** The opt-in `file` container connected to the MQ, received every line and wrote NOTHING: `setup()` was driven from `processProperties.bundles`, which `lib/logger/src/helper.js:125` fills only when `process.argv` matches `bundle:(start|stop|restart)` — while `core/gna.js:294` splices `process.argv` down to `[node, appPath]` in every bundle process loaded through the CLI, so that list is structurally always empty and no filename was ever assigned. Fixing the filename exposed what the dead sink had been hiding, measured on a live daemon topology: **(a)** the container dialled the MQ port at construction and ran `process.exit(0)` from its socket error handler, and in the daemon that dial happens BEFORE the daemon's own listener binds — so `gina start` died before "Framework ready" 3/3 whenever the flow was enabled, and a daemonless bundle exited with status 0 one second after boot; **(b)** the listener FORWARDS every speaker's line to every `writeToFile` session, so every process carrying the flow wrote EVERY bundle's lines into one shared per-host file (measured twice-over with two bundles) and built a full `Config` for a bundle it is not, which crashed `gina bundle:start` 3/3; **(c)** N processes appending to one file each kept their own rotation state and size counter, so rotation raced and lost lines silently. **The sink is therefore no longer MQ-coupled: it listens on the `logger#file` event `emit()` already raises for every flow, exactly like `containers/default`, and writes only the lines ITS OWN process logged.** No socket, no daemon dependency (it works under `gina-container` and in a container), no foreign `Config`, one writer per file. The file is named after the LOG GROUP — `<logdir>/<bundle>@<project>.log` — which is what makes "one writer" true; a group without `@` (the CLI's and the daemon's own `gina` lines) is not filed, matching the guard the old `write()` already applied. Records are written WITHOUT ANSI escapes, and `GINA_LOG_FORMAT=json` is honoured with the same line shape the stdout container writes. Rotation is configured under `rotate` in `~/.gina/user/extensions/logger/file/config.json` — the machine-level surface that also ENABLES the container (it reaches the container because `loadContainers()` merges `flowsOptions[flow]` over the logger options with override:true). Keys: `enabled` (default true), `when` (`daily`|null), `size` (default `10MB`), `count` (default 5), `maxAge` (e.g. `30d`, off). Mechanism is rename-then-reopen, never copy-then-truncate: the retired vendored rotator (MIT/Capriza, undeclared in either manifest so invisible to Socket and Dependabot, and unreachable — its `require` path resolved to a directory that does not exist) copied then truncated, losing every line appended during the copy window. ⚠️ The descriptor is opened SYNCHRONOUSLY and the stream wrapped over it: `createWriteStream(path)` opens asynchronously, so a burst of lines logged within one tick reaches a size trigger while the file does not exist on disk yet and the cascade's `renameSync` throws — measured with 60 lines in one tick. A size or age without an explicit unit is REFUSED, never guessed, and any invalid value disables rotation with a message naming key, value and consequence — reported THROUGH THE FLOWS (`process.emit('logger#'+flow, …)`, one payload per flow, which is what `emit()` itself does), never through raw `process.stdout.write`: under a daemon `lib/cmd/bundle/start.js` consumes the child's stdout and relays nothing after start, so the old raw-stdout refusal reached NOBODY (measured 0 in the daemon log, 0 at a tail client, 0 in the file, 0 in the CLI output). Logging continues either way. Two bounds stated honestly: bytes still queued when the process exits can be lost (`end()` is asynchronous, and making every write synchronous would let a full disk block the request loop), and a stream that stops draining past a 4 MB buffer drops lines with one warning per outage — the same posture the MQ speaker documents. ⚠️ **Generalisable trap that cost this arc most of its time: the repo-root `utils/prototypes.js` installs `Object.prototype.count` + `functionCount` and `Array.prototype.clone` + `inArray` ON REQUIRE, unguarded — so `typeof(anyObject.count)` is `'function'` for EVERY object including `{}`.** A `typeof(user.<key>) != 'undefined'` guard over a user-config object therefore hands the inherited METHOD back as the configured value — here `~~(function)` is 0 and rotation disabled itself reporting "count is undefined" on a valid default. ⛔ Do NOT cite or patch `helpers/prototypes.js:169`: it declares the same names, but its guard `typeof(Object.count) == 'undefined'` reads the INHERITED method once `utils/` has run (`Object` inherits through `Function.prototype`), so every declaration there is skipped — the trap defeats its own guard and that line is dead code. The properties are `enumerable: false`, so `Object.keys()` reports `[]` and `JSON.stringify` drops them, which is exactly why every probe read the key as ABSENT rather than as a function. ⚠️ An isolated repro is the CHEAPEST way to see this, not an impossible one — `node -e "require('./utils/prototypes.js'); console.log(typeof({}).count)"` prints `function` in a plain process with no framework and no invocation (control: without the require it prints `undefined`). What DOES false-negative is requiring the OTHER file: `helpers/prototypes.js` exports a constructor that must be CALLED, so a bare require of it installs nothing and reports every key absent — inside the framework tree too, which is why that probe reads like a legitimate in-context measurement. Pair any such probe with a known-positive control. ⚠️ **The same collision has a SECOND, REQUEST-FACING direction — #B546 (0.6.31):** the trap above is the framework READING a config key that the helper makes look present; the mirror is the framework CALLING `<container>.count()` on a container whose keys the CLIENT chooses (parsed body, query bag, route params, `req.files`, `self.query()` data, a routing rule's validator data, and `| length` on BOTH template engines). There an own field named `count` shadows the helper and the framework calls a string — see #211 for the impact and the `ownCount()` remedy, applied at 27 sites across `core/server.js`, `core/controller/controller.js` and the validator engine. Counts over FRAMEWORK-built objects (response headers, the routing table, config) deliberately keep the shorthand, and the helper itself stays on `Object.prototype` for application code. Use `Object.prototype.hasOwnProperty.call(obj, key)` for any own-key test over config in this framework, never `typeof` — the guard is name-independent, so it is right for all four names and any added later. **#B524 (0.6.30) — `gina-container` applies the container preset ITSELF.** Unless `GINA_LOG_STDOUT` is already set, the launcher writes `process.env.GINA_LOG_STDOUT = 'true'` ABOVE its `utils/helper` require — that require initialises the launcher's OWN logger, and an unconfigured container was measured running TWO redialling speakers (launcher + bundle), each warning `ECONNREFUSED 127.0.0.1:8125` once and then dialling every ≤30 s for the life of the container — and the bundle inherits it through the spawn; `image:build` images inherit it for free, their entrypoint being `gina-init && exec gina-container`. It MUST be a raw `process.env` write (`setEnvVar()` populates `process.gina`, which the logger never reads). Explicit values win both ways, measured on isolated boots: `GINA_LOG_FORMAT=text` ⇒ coloured text with the dial still skipped; `GINA_LOG_STDOUT=false` ⇒ the pre-0.6.30 behaviour byte-for-byte. ⚠️ LAUNCHER topology only: a bundle started through a framework daemon (`gina start` → `bundle:start`) is untouched — there the MQ transport is what `gina tail` reads, and the daemon DISCARDS the bundle's own stdout after startup (`bundle/start.js` `if (isStarting) return;`), so the relay is the only path a runtime line has to an operator. That topology's JSON answer is the OTHER half of #B524: **`gina tail` renders one JSON object per line when its OWN logger's resolved format is `json`** (`GINA_LOG_FORMAT=json` on the tail process — the pod's environment reaches it; `lib/cmd/framework/tail.js` reads `console.getOptions().format`, the logger's single precedence rule, and routes BOTH render sites — the delayed-message replay and the live stream — through one `renderLine(pl)`; same `{ts, level, bundle, message, group, msg}` shape, NO `requestId`/`durationMs` because the relay payload is `{group, level, content}` only). Measured on a daemon-free relay scene (a real `MQListener` on an isolated port + a `gina-container` bundle booted with `GINA_LOG_STDOUT=false` so it speaks + `bin/cli tail`): format unset ⇒ 5 ANSI lines, 0 JSON; `json` ⇒ 5 JSON objects, 0 escapes, the tail's own connect lines included. ⛔ `GINA_LOG_STDOUT=true` is NOT the daemon topology's switch — it never reaches a daemon-spawned bundle's logger (#B532: `bin/cli:336` `filterArgs()` MOVES every `GINA_*` key out of `process.env` before `bundle/start.js:404` spawns with no `env:`, and the child's logger initialises at `gna.js:183` BEFORE the ctx mirror at `:276` restores them — so the tutorial's `GINA_LOG_FORMAT=json gina bundle:start` cannot reach the bundle either, and a daemon-topology JSON line can never carry `requestId` until that is fixed), and if it did reach the bundle it would silence the relay the tail reads.
|
|
943
|
+
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`. **The contextless `require('gina')` boundary + MQ-speaker transport resilience (moved here from the DTO entry that surfaced it; #B276/#B277 0.6.4, #B318 0.6.5, #B323 fixed post-0.6.5 — shipped 0.6.6 with the reconnect).** Importing gina outside a spawned bundle child has ALWAYS been an intended boundary (`index.mjs`'s own docblock states it; both published entries throw alike), but two defects sat on top of it. **(1) The throw was uncatchable and could HANG the process.** The logger is a load-time singleton whose 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 (listener-CONTINGENT, measured both ways: nothing bound to the MQ port ⇒ ECONNREFUSED ⇒ 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: a THIRD state exists beyond connected/REFUSED — an UNREACHABLE host, where `unref()` covers the socket HANDLE but not the pending `TCPConnectWrap` REQUEST, which holds the loop by itself for the OS connect timeout (~75s on macOS); fixed with an unref'd connect deadline (2s default, `opt.mqConnectTimeout`) that `destroy(err)`s only while `client.connecting`. ⚠️ **That safety claim — "only while connecting" — is MEASURED FALSE and shipped a HIGH regression for one release (#B323):** `client.connecting` does NOT flip when the kernel completes the connect; it flips when the POLL phase runs `afterConnect` — and a timer fires in the TIMERS phase, which precedes poll. On any boot that blocks the event loop past the deadline (a bundle mount off a network filesystem — 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 (the speaker dialled exactly once, ever). Measured both directions: 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. The fix defers the verdict one phase — `setImmediate` INSIDE the deadline, so the read happens in the CHECK phase, after poll — a completed connect survives while a genuinely pending one still dies on schedule (#B318's no-hang contract intact). **Generalisable: 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; a timers-phase guard over I/O state wants a `setImmediate` (or the event itself), never a direct read. **The speaker also RECONNECTS (0.6.6):** `startMQSpeaker` returns a stable `{write}` transport FACADE (`mq/index.js` captures the return ONCE for the life of the process — without the indirection a replaced socket is 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 — a shared object would make every reconnect skip its acknowledgement); `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 BOTH ways: a short-lived CLI still exits while a long-lived bundle keeps retrying and HEALS if a listener appears later. The caller callback settles ONCE, the warn fires once per OUTAGE (not per retry), 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. ⚠️ The socket still CONNECTS — `unref` changes only whether it votes on process lifetime (consumer-verified against a live listener: the connected line still logs; "unref'd" is not "suppressed"). ⚠️ The workaround `GINA_LOG_STDOUT=true` does splice `mq` out of the flows but 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 boundary's error now names the boundary** — the old message named an internal call (`setPath("gina.home", path): path cannot be empty`) because `getEnvVar` reads `process.gina` ONLY and is empty outside the CLI; it now routes the reader to `SuperController.createTestInstance()`, the supported way to exercise controller code without booting. ⛔ **Do NOT "fix" the boundary by extending the `getEnvVar → process.env` ladder to `gna.js`, nor by skipping the empty `setPath`** — REFUTED BY MEASUREMENT: satisfying that call merely defers the failure 8 lines to a bare-global `ReferenceError` (strictly less legible), and that `setPath` is the SOLE registration site for `gina.home` repo-wide (the SQLite connector and session store read it). Failing fast at the first detectable point is correct. Diagnostic instrument for the hang class: `process._getActiveRequests()` reports `TCPConnectWrap` while `_getActiveHandles()` shows only the unref'd Socket. Tests: `test/lib/logger-mq-speaker-resilience.test.js` (§01 deferral-ordering + reconnect-shape pins; §02 a child that blocks 3s right after the dial and must still deliver — red-first: pre-fix it lost the frame while the connection was ESTABLISHED, the defect's own signature; §03 a listener destroyed and rebound must be heard again; §04 the no-listener child still exits promptly) + `logger-mq-speaker-unref.test.js` (comment-stripped source pin — subtracting the fix left a bare existence pin GREEN against the commented-out line — + a behavioural arm with its own must-be-killed control). **Log redaction (#B433, 0.6.19):** every message is redacted BEFORE it is rendered and dispatched — inside the logger's `emit()`, the single pre-render point all 46 URL-bearing framework sites pass through (both engines, every render delegate, the CSRF filter; all levelled, none `console.log`), plus the raw `console.log` path — so stdout, the MQ speaker/`gina tail`, the file transport and the Inspector taps all receive the same masked line and a JSON line cannot be corrupted (the marker lands inside the message string). Configured per bundle in `settings.json > log.redact` `{enabled, defaults, secrets, patterns}`, ON by default: `defaults` = JWT · URL userinfo password · `Bearer`/`Basic` · named credential query keys (`token`, `access_token`, `api_key`, `secret`, `password`, `signature`, `otp`, …, key kept / value masked) · api-key headers — measured 0 false positives on 433k real log lines, ~1 µs per line, linear patterns; `secrets` = every value the secrets resolver substituted for a `${secret:KEY}` placeholder (settings, connectors, routing — one resolve point) masked verbatim, values < 8 chars skipped with a boot warn; `patterns` = consumer rules (regex source string → `[REDACTED]`, or `{pattern, flags, replacement, name}` with `$1` kept), `g` always enforced. Deliberately NOT a default: a bare long-hex path segment (a sha256 content-address key from storage and an opaque 64-hex credential are the same regex class — the consumer adds `(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])` — anchored on the class, not `\b`, which never fires against a prefixed `key_<hex>` segment). Fail-closed: an unknown key, a non-boolean flag, an invalid or empty-matching pattern refuses the boot (a dropped rule is a leak). Installed by `core/config.js::loadBundleConfig` right after the bundle's `secrets.resolve()` — the earliest resolved point, so connector-init lines are already covered — via `console.setRedaction(block, {group, secrets: lib.secrets.getResolvedValues(conf)})`; state lives on the logger's persisted context (survives `refreshCore()`), keyed by bundle, UNION across bundles (one bundle enabling is enough in a merged process; a per-route `logUrl:false` flag was rejected by measurement — it cannot reach the second URL copy inside the #ERRREF error stack). The 404 ref line carries the URL twice in ONE record (prefix + the error's own message); both copies are masked. Restart to apply, no re-bake (server-side only). A per-route or per-site flag is NOT the extension point; the logger seam is. Error rendering (#B434, 0.6.19): an `Error` passed as a log argument renders via `util.inspect` — message, stack, own props, `[cause]` chain — at BOTH stdout writers (the levelled `write()`/`parse()` walk and the raw `console.log` `JSON.stringify` path each saw only ENUMERABLE own props, so a bare Error rendered `{}`), top-level and nested alike, realm-safe (`util.types.isNativeError` beside `instanceof`), and the rendered message still crosses the redaction seam (content is built in `write()`, redacted in `emit()`). Non-Error arguments render byte-identically to before. **The `file` log sink — an IN-PROCESS transport, and the three defects that made it unusable (#B523/#B526–#B531, 0.6.30).** The opt-in `file` container connected to the MQ, received every line and wrote NOTHING: `setup()` was driven from `processProperties.bundles`, which `lib/logger/src/helper.js:125` fills only when `process.argv` matches `bundle:(start|stop|restart)` — while `core/gna.js:294` splices `process.argv` down to `[node, appPath]` in every bundle process loaded through the CLI, so that list is structurally always empty and no filename was ever assigned. Fixing the filename exposed what the dead sink had been hiding, measured on a live daemon topology: **(a)** the container dialled the MQ port at construction and ran `process.exit(0)` from its socket error handler, and in the daemon that dial happens BEFORE the daemon's own listener binds — so `gina start` died before "Framework ready" 3/3 whenever the flow was enabled, and a daemonless bundle exited with status 0 one second after boot; **(b)** the listener FORWARDS every speaker's line to every `writeToFile` session, so every process carrying the flow wrote EVERY bundle's lines into one shared per-host file (measured twice-over with two bundles) and built a full `Config` for a bundle it is not, which crashed `gina bundle:start` 3/3; **(c)** N processes appending to one file each kept their own rotation state and size counter, so rotation raced and lost lines silently. **The sink is therefore no longer MQ-coupled: it listens on the `logger#file` event `emit()` already raises for every flow, exactly like `containers/default`, and writes only the lines ITS OWN process logged.** No socket, no daemon dependency (it works under `gina-container` and in a container), no foreign `Config`, one writer per file. The file is named after the LOG GROUP — `<logdir>/<bundle>@<project>.log` — which is what makes "one writer" true; a group without `@` (the CLI's and the daemon's own `gina` lines) is not filed, matching the guard the old `write()` already applied. Records are written WITHOUT ANSI escapes, and `GINA_LOG_FORMAT=json` is honoured with the same line shape the stdout container writes. Rotation is configured under `rotate` in `~/.gina/user/extensions/logger/file/config.json` — the machine-level surface that also ENABLES the container (it reaches the container because `loadContainers()` merges `flowsOptions[flow]` over the logger options with override:true). Keys: `enabled` (default true), `when` (`daily`|null), `size` (default `10MB`), `count` (default 5), `maxAge` (e.g. `30d`, off). Mechanism is rename-then-reopen, never copy-then-truncate: the retired vendored rotator (MIT/Capriza, undeclared in either manifest so invisible to Socket and Dependabot, and unreachable — its `require` path resolved to a directory that does not exist) copied then truncated, losing every line appended during the copy window. ⚠️ The descriptor is opened SYNCHRONOUSLY and the stream wrapped over it: `createWriteStream(path)` opens asynchronously, so a burst of lines logged within one tick reaches a size trigger while the file does not exist on disk yet and the cascade's `renameSync` throws — measured with 60 lines in one tick. A size or age without an explicit unit is REFUSED, never guessed, and any invalid value disables rotation with a message naming key, value and consequence — reported THROUGH THE FLOWS (`process.emit('logger#'+flow, …)`, one payload per flow, which is what `emit()` itself does), never through raw `process.stdout.write`: under a daemon `lib/cmd/bundle/start.js` consumes the child's stdout and relays nothing after start, so the old raw-stdout refusal reached NOBODY (measured 0 in the daemon log, 0 at a tail client, 0 in the file, 0 in the CLI output). Logging continues either way. Two bounds stated honestly: bytes still queued when the process exits can be lost (`end()` is asynchronous, and making every write synchronous would let a full disk block the request loop), and a stream that stops draining past a 4 MB buffer drops lines with one warning per outage — the same posture the MQ speaker documents. ⚠️ **Generalisable trap that cost this arc most of its time: the repo-root `utils/prototypes.js` installs `Object.prototype.count` + `functionCount` and `Array.prototype.clone` + `inArray` ON REQUIRE, unguarded — so `typeof(anyObject.count)` is `'function'` for EVERY object including `{}`.** A `typeof(user.<key>) != 'undefined'` guard over a user-config object therefore hands the inherited METHOD back as the configured value — here `~~(function)` is 0 and rotation disabled itself reporting "count is undefined" on a valid default. ⛔ Do NOT cite or patch `helpers/prototypes.js:169`: it declares the same names, but its guard `typeof(Object.count) == 'undefined'` reads the INHERITED method once `utils/` has run (`Object` inherits through `Function.prototype`), so every declaration there is skipped — the trap defeats its own guard and that line is dead code. The properties are `enumerable: false`, so `Object.keys()` reports `[]` and `JSON.stringify` drops them, which is exactly why every probe read the key as ABSENT rather than as a function. ⚠️ An isolated repro is the CHEAPEST way to see this, not an impossible one — `node -e "require('./utils/prototypes.js'); console.log(typeof({}).count)"` prints `function` in a plain process with no framework and no invocation (control: without the require it prints `undefined`). What DOES false-negative is requiring the OTHER file: `helpers/prototypes.js` exports a constructor that must be CALLED, so a bare require of it installs nothing and reports every key absent — inside the framework tree too, which is why that probe reads like a legitimate in-context measurement. Pair any such probe with a known-positive control. ⚠️ **The same collision has a SECOND, REQUEST-FACING direction — #B546 (0.6.31):** the trap above is the framework READING a config key that the helper makes look present; the mirror is the framework CALLING `<container>.count()` on a container whose keys the CLIENT chooses (parsed body, query bag, route params, `req.files`, `self.query()` data, a routing rule's validator data, and `| length` on BOTH template engines). There an own field named `count` shadows the helper and the framework calls a string — see #211 for the impact and the `ownCount()` remedy, applied at 27 sites across `core/server.js`, `core/controller/controller.js` and the validator engine. Counts over FRAMEWORK-built objects (response headers, the routing table, config) deliberately keep the shorthand, and the helper itself stays on `Object.prototype` for application code. Use `Object.prototype.hasOwnProperty.call(obj, key)` for any own-key test over config in this framework, never `typeof` — the guard is name-independent, so it is right for all four names and any added later. **#B524 (0.6.30) — `gina-container` applies the container preset ITSELF.** Unless `GINA_LOG_STDOUT` is already set, the launcher writes `process.env.GINA_LOG_STDOUT = 'true'` ABOVE its `utils/helper` require — that require initialises the launcher's OWN logger, and an unconfigured container was measured running TWO redialling speakers (launcher + bundle), each warning `ECONNREFUSED 127.0.0.1:8125` once and then dialling every ≤30 s for the life of the container — and the bundle inherits it through the spawn; `image:build` images inherit it for free, their entrypoint being `gina-init && exec gina-container`. It MUST be a raw `process.env` write (`setEnvVar()` populates `process.gina`, which the logger never reads). Explicit values win both ways, measured on isolated boots: `GINA_LOG_FORMAT=text` ⇒ coloured text with the dial still skipped; `GINA_LOG_STDOUT=false` ⇒ the pre-0.6.30 behaviour byte-for-byte. ⚠️ LAUNCHER topology only: a bundle started through a framework daemon (`gina start` → `bundle:start`) is untouched — there the MQ transport is what `gina tail` reads, and the daemon DISCARDS the bundle's own stdout after startup (`bundle/start.js` `if (isStarting) return;`), so the relay is the only path a runtime line has to an operator. **#B691 (0.7.1) — the one exception, during the boot itself:** the MQ listener keeps NO backlog (`listener.js` `report()` forwards each frame to the tails attached at that instant) and `gina tail` cannot ask for earlier frames, so a tail attached after the boot — the ordinary `bundle:start`-then-`tail` order, and a container init script that tails after starting — saw none of a bundle's boot-time lines, including the 0.7.0 `autoescape` and unanchored-`requirements` warnings. The startup watchdog now passes the bundle's warn-and-above boot lines on to the `bundle:start` client (so also to `bundle:restart`, which prints its start step): `warn`/`warning`, `error`/`err`, `crit` and `alert` (the logger renders the name the caller used, and recommends `warning`/`err`); `emerg` keeps its `aborted :(` path. The filter is `lib/cmd/bundle/inc/boot-lines.js`: it buffers by line across chunks, reads the level from the ANSI-stripped `[date] [level ][group]` header or from a JSON line's `level`, keeps a multi-line entry whole until its colour block closes (`format()` wraps the whole message in one), and caps a line and an entry at 64 KB. It runs after the `isStarting` guard and before the EADDRINUSE/emerg/started checks, so a warning comes before `started V(-.o)V`, which then sits on its own line; with no such line the output is byte-identical. A tail attached first still gets the lines, so they show twice there (accepted). Runtime lines stay the tail's. Pickup = a DAEMON restart (`framework:init` requires a command module once), not a `bundle:restart`. Tests: `test/lib/bundle-start-boot-lines.test.js`. That topology's JSON answer is the OTHER half of #B524: **`gina tail` renders one JSON object per line when its OWN logger's resolved format is `json`** (`GINA_LOG_FORMAT=json` on the tail process — the pod's environment reaches it; `lib/cmd/framework/tail.js` reads `console.getOptions().format`, the logger's single precedence rule, and routes BOTH render sites — the delayed-message replay and the live stream — through one `renderLine(pl)`; same `{ts, level, bundle, message, group, msg}` shape, NO `requestId`/`durationMs` because the relay payload is `{group, level, content}` only). Measured on a daemon-free relay scene (a real `MQListener` on an isolated port + a `gina-container` bundle booted with `GINA_LOG_STDOUT=false` so it speaks + `bin/cli tail`): format unset ⇒ 5 ANSI lines, 0 JSON; `json` ⇒ 5 JSON objects, 0 escapes, the tail's own connect lines included. ⛔ `GINA_LOG_STDOUT=true` is NOT the daemon topology's switch — it never reaches a daemon-spawned bundle's logger (#B532: `bin/cli:336` `filterArgs()` MOVES every `GINA_*` key out of `process.env` before `bundle/start.js:404` spawns with no `env:`, and the child's logger initialises at `gna.js:183` BEFORE the ctx mirror at `:276` restores them — so the tutorial's `GINA_LOG_FORMAT=json gina bundle:start` cannot reach the bundle either, and a daemon-topology JSON line can never carry `requestId` until that is fixed), and if it did reach the bundle it would silence the relay the tail reads.
|
|
944
944
|
|
|
945
945
|
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 — and since #B574 (2026-09-20) `popinOpen`'s own re-entry guard reads the same `hasAttribute('open')`: it read getAttribute truthiness, never skipped a shell-opened dialog, and on the new trigger's default MODELESS resolution called `show()` on the still-modal shell, which throws `InvalidStateError` and left `isOpen` false on a visibly open popin; 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); and since gh#76 §8 (2026-09-20) the `preOpen` loading shell is an explicit STATE — `$popin.isLoading` is true from the moment the shell shows until the real open (`isOpen` stays false, so the two `!isOpen ⇒ popinOpen` consumers still run it), backed by a per-popin load sequence and the in-flight transport (`_loadSeq` / `_loadXhr`): `close()` / `destroy()` on a loading popin cancel the load (`closeLoadingShell` — sequence bump, `abort()`, teardown by the shell's OWN state because the class-keyed teardown never matches a dialog shell, trigger release, `close.<id>`; the aborted transport's readyState-4 handler runs its release block and then drops on the sequence check, so a cancel fires no `error`), the native `close` sync is bound at SHELL time through `bindNativeCloseSync` (guard `isOpen || isLoading`) so an Escape during the load routes through the plugin instead of re-opening when the content lands (driven on the pre-fix bundle: Escape closed the dialog, the landing re-opened it), `loadContent()` on a loading popin injects into the DIALOG element (`target` still names the container until popinOpen re-points it) and completes the open through popinOpen, and a non-2xx load fires `error.<id>` FIRST, then closes the shell if nothing loaded content into it; a popin without `preOpen` never enters the state, and `getActivePopin()` stays open-only; and #B579 (2026-09-20): the declarative trigger's `loaded.<id>` listener applies a NON-STRING detail as nothing — `popinLoadContent`'s redirect branch fires `loaded.<id>` with the POPIN OBJECT as detail (the legacy listener only binds on it), so a `load()` landing on an already-OPEN `data-gina-dialog` popin wrote the new body and then wiped it, leaving the dialog empty with no error (driven on the published bundle; every version since 0.4.6). (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.6).** `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. **The popin `::backdrop` rule is scoped to gina's own dialogs (#B329, fixed 2026-08-24).** The sheet shipped `dialog::backdrop` (dark overlay + blur) UNSCOPED, painting gina's backdrop on EVERY consumer `<dialog>` on the page; now `dialog.gina-popin-container::backdrop`, matching the reduced-motion rules that were already scoped. Both dialog-creation sites set the container class on the `<dialog>` element itself, so gina's own popins keep their backdrop. Consumer-visible: a page relying on the free backdrop for its own dialogs styles them itself now, and a popin dialog element supplied by the page's own markup (the adopt-by-id path) needs `gina-popin-container` to keep gina's backdrop. Pins: `popin-backdrop-scope.test.js` (source + compiled twin + `gina.min.css`, present/absent controls). Browser-bundled ⇒ consumers restart AND re-bake. **A form's or a link's `text/html` XHR answer is routed by the popin the SUBMITTING element is inside, never by "the active popin" (gh#76 + #B571, 2026-09-20).** The validator captures `gina.popin.getPopinContaining($form)` into the per-send closure at submit (the same capture stamps the `X-Gina-Popin-Id`/`-Name` request headers, so `self.isPopinContext()` is true only for a contained form) — and since #B572 (2026-09-22) the STAGED-UPLOAD path makes the same capture once per file selection (`$ownerPopin = getPopinContaining($el.form || $el)`, `$el.form` being the consumer's real form), so the virtual `gina-upload-*` form, its preview lookups, its `uploadProperties.isPopinContext` flag and its staging request belong to the popin the REAL form is inside, or to `document.body`. It was the LAST surviving `isPopinContext()` (« some popin is open ») gate: a PAGE form's file chosen while an unrelated popin was open had its virtual form appended inside that popin — staging 200, all ten hidden metadata fields EMPTY, the save posted without the file, the staging request claiming that popin's id, zero errors — reachable through the file-picker race (the OS picker is application-modal and ignores `inert`, which blocks only the click path) and through script-assigned `input.files`; a form INSIDE the popin was always fine and still is (measured 10/10 both ways). Dev-mode notice `[FormValidator][popin] the staged upload of `#<input>` (form `#<form>`) is placed with its own form …`, from `warnIfOldRulePlacedUpload`, the twin of `warnIfOldRuleRouted`; the popin branch's `$target` DOMParser copy is deliberately untouched (measured working when chosen by containment). Pins: `test/core/validator-upload-popin-containment.test.js` (block-scoped absence pin + the helper driven in a lifted scope with every free identifier supplied), e2e `validator-upload-popin-containment.spec.js` (page form + open popin, the picker race, and the inside-the-popin control, red-first through `B572_PREFIX_BUNDLE`). and honours it at settle only while that popin is still open AND its element still contains the form (a close + reopen in flight detaches the form ⇒ legacy payload); anything else — a page form while a popin is open OR loading — is the legacy handler-only `{contentType, content, status}` payload, with a dev-mode `[FormValidator][popin]` notice naming the popin the old rule would have targeted (it REPLACED an open popin's content, or raised a false `422` `Popin x is not open !` on a loading one — the loading window is reachable through a preload-opted-out trigger, never a hovered default one, whose click adopts the warm). The popin branch now also emits the `.hform` (validator) / `.hlink` (`utils/events.js`, whose `.hform` half is dead — no caller passes `$form`) companions with the parsed xhr-data, so declared callbacks run for forms inside popins (#B571). `getActivePopin()` returns OPEN popins only — the `activePopinId` one when open, else the first open — never a registered-but-not-open one (§8(a)); `popinLoadContent` honours `this` when called as a method (both internal sites use `.call($popin, …)`, the manager-level call keeps the active fallback); the redirect branch resolves `sendCtx.popin || (result.popin ? getActivePopin() : null)` — a plain `location` redirect from a page form falls through to the page redirect (measured pre-fix: it loaded INTO the open popin AND navigated, two fetches), a name-less `{popin:{close}}` with nothing open is a no-op. `getPopinContaining` walks the registry with `getElementById(id).contains(el)` (innermost wins; the element id is the popin id on every flavour; 0.2 µs worst case) — a non-modal popin still inerts the page, so a page form can only be submitted by script while one is open, which is what the request-side arms of `test/e2e/validator-popin-form-target.spec.js` do. Companion `test/e2e/popin-preopen-modeless.spec.js` pins #B574 (`popinOpen` guard `hasAttribute('open')`). Browser-bundled ⇒ consumers restart AND re-bake. **#B575 (0.6.32) — the two hidden transport inputs are DEV-ONLY, and both popin branches now parse tolerantly.** `spliceXhrInputs` runs only under `_isDev` (`NODE_ENV_IS_DEV === 'true'`), so outside dev mode a `renderWithoutLayout` answer carries NO `gina-without-layout-xhr-data`/`-view`; both branches dereferenced `.value` on the absent element, the `TypeError` was caught INSIDE the try and surfaced as a false `422` error callback AFTER a successful server write, and the popin was never loaded. One shared `parseXhrHtmlAnswer()` now returns `{doc, data, view}` with `data`/`view` `null` when absent and the two inputs STRIPPED from the parsed document (they are consumed transport — left in, a swap into a region inside a `<form>` would resubmit them and repeated swaps would duplicate their ids). ⚠️ The parsed data is delivered VERBATIM when present — injecting a `status` key would change the payload every contained form already receives (the slice-1 contract `validator-popin-form-target.spec.js §05` pins) — and the payload is `{status}` only when the inputs are absent; `validator-form-target.test.js §06` pins that absence SCOPED to the popin branch, since `result.status = xhr.status` is legitimate elsewhere (the legacy payload builders in `utils/events.js`). (14) **`data-gina-dialog-target` is ONE selector applied on BOTH sides, with THREE fallbacks — all announced in dev mode since #B580 (2026-09-22).** `applyContent($el, html, $popin, partialTarget)` reads the slot with `$el.querySelector(partialTarget)` and the incoming region with `parsed.querySelector(partialTarget)` over a `new DOMParser()` document, then assigns `$slot.innerHTML = $incoming.innerHTML` — the SLOT ELEMENT survives, which is what preserves chrome and its bindings. The three misses are all fail-soft (a popin open is a retryable read, unlike a submit): a selector the ENGINE REFUSES ⇒ full replace — pre-#B580 this threw an uncaught `SyntaxError` out of the `loaded.<id>` handler / preload-consume tail, so the dialog silently kept its previous content (MEASURED on the real bundle: `Failed to execute 'querySelector' on 'Element': '#slot >' is not a valid selector.`); no match in the OPEN DIALOG ⇒ full replace; no match in the ANSWER ⇒ the whole `parsed.body` into the slot — the SECOND fallback, which the published guide did not mention and nothing pinned. `warnPartialTarget()` emits one `console.warn` per event naming the popin, the selector and which fallback ran, gated on `gina.config.envIsDev`. ⚠️ Testing surface: the e2e harness (`test/e2e/runtime-server.js` serves `envIsDev:'false'`) can assert only the FALLBACK — the NOTICE belongs to `popin.test.js §22`, which drives the REAL brace-walk-extracted function, because the §21 replica omits `$popin` and mirrors the pre-fix body; keep `applyContent`'s signature and first `if ( !partialTarget )` branch byte-shaped or the regex pins at `popin.test.js:1121-1130` and `popin-reload-open.test.js:61` go red. ⚠️ The attribute is the dialog-scoped sibling of `data-gina-form-select`, NOT of `data-gina-form-target`: one selector, both sides, nothing resolved relative to an element, so the `this`/`closest x`/`find x`/`next x` grammar does not apply — all four are valid CSS, so writing one takes the silent fallback instead of reporting a mistake. ⚠️ The "legacy triggers get a full replace" rule covers PURE legacy only: `bindDelegatedOpen` returns early ONLY for a trigger carrying NEITHER `data-gina-dialog` NOR `data-gina-dialog-src`, so a MIXED trigger (legacy attributes + `-src`) routes through `openFromTrigger` and DOES get the partial swap.
|
|
946
946
|
|
|
@@ -960,7 +960,7 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
960
960
|
226. **Dev Inspector component observability (#CC6, `0.5.15-alpha.2`) — View-tab census + client protocol-event capture, both statusbar-collected**: the census (instances per custom-element tag + the platform `:not(:defined)` awaiting-upgrade set — an undefined custom tag is otherwise a perfectly silent failure) MUST be collected in-page by the statusbar and shipped over the per-tab channel as `user.view.components`, because the Inspector SPA has NO DOM access in bound/COOP mode (`source` is the string `broadcast` — live opener reads yield nothing); it re-collects on toolbar updates plus a 150ms deferred refresh so XHR/popin fragments land first; the SPA renders it bespoke (key added to VIEW_SKIP) with a red `.bm-comp-undefined` indicator. Client `<tag>:<verb>` CustomEvents ride a dev-only `EventTarget.prototype.dispatchEvent` wrap (the client runtime's FIRST DOM-prototype wrap — native-apply idiom, capture contained so observability can never break dispatch; the dashed-tag name filter `/^[a-z][a-z0-9]*(?:-[a-z0-9]+)+:./` naturally excludes the framework's internal dot-namespaced events): name/target/timestamp always, `detail` VALUES only behind `settings.inspector.events.captureArgs` — which is server-side-only (`process.gina._inspectorEventsCaptureArgs`), so it reaches the browser via a NEW `__gdGina.inspectorEventsCaptureArgs` whisper in BOTH render delegates (the inspectorUrl/inspectorRedact vehicle — an inspector capture gate that a client surface needs must be whispered; neither the events nor the AI-stream gate reached the client before); captured detail still passes the statusbar redaction walk; capped buffer (200) rides `user.clientEvents` with coalesced publishes; rendered textContent-only below the server events (NO new tab — the 8-tab preset pins stay untouched). Editing inspector SPA/statusbar src is INERT on a live dev inspector until the dist build copies it (served per-request from dist); the inspector tests read dist too — rebuild before the suite.
|
|
961
961
|
|
|
962
962
|
|
|
963
|
-
236. **`helpers/context.js` `throwError` no longer crashes when called from a DETACHED context (cron/timer/worker/bootstrap) that holds a stale global `router` (0.5.18-alpha.2).** This is the GLOBAL helpers' error path (used by `getLib`/`getConfig`/context bootstrap) — distinct from the controller's `self.throwError(response, code, …)`. `core/router.js:484` stores each request's `{response, next, hasViews, isUsingTemplate}` in a GLOBAL context slot (`setContext('router', …)`) and NEVER clears it, and `throwError` (`context.js:284`) is its SOLE reader — so after any request finishes a stale routerObj lingers: a finished `response` (`headersSent === true`) and a non-callable `next`. A caller outside a live request (a `setTimeout` cron, a worker, a bootstrap-time `getLib()`) then entered the request-response branch and called the stale `next()` → `TypeError: next is not a function`, which additionally MASKED the real error it was reporting (e.g. the actual reason a cron's `getLib()` failed to load its lib — and the `getLib` inner catch even passes `isFatal=true` intending the emerg-log-and-return path, but pre-fix `isFatal` was honored ONLY in the no-router branch, so a stale router defeated it). Restructured into explicit guarded outcomes: render an error response only when a live response is still WRITABLE (`res && !res.headersSent`); hand back to the chain only when `next` is GENUINELY a function (`res && res.headersSent && typeof next == 'function'`); otherwise honor `isFatal` (`console.emerg` + return) or `throw err` — so a detached caller SURFACES the real error instead of crashing on a dead `next`, and the underlying failure becomes visible in the logs. All live-request behaviour is byte-identical (the always-JSON `isUsingTemplate = isUsingTemplate` self-assignment quirk — always `undefined`, so the HTML error-page branch is dead code — was deliberately PRESERVED; fixing it flips template-bundle error responses JSON→HTML, a separate behavioural decision). Because `throwError` is the router context's only reader, making IT robust fully covers all 6 call sites (bootstrap `:72`, getConfig `:437/:457`, getLib `:578/:583/:588`); clearing the router context per-request is a separate, higher-blast-radius hardening not needed for this class. Rule: any framework helper reachable from a non-request context (crons/timers/workers/bootstrap) must not assume a live `req`/`res`/`next` — the global `router` slot may be stale; gate `next()` on `typeof next == 'function'` and never write to a `headersSent` response. Server-side only — NO dist rebuild. Tests: `test/core/context-throwerror.test.js` (source pins + pure-logic replica + a subtract control reproducing the exact `TypeError`). Commit `6b08618d`, established 2026-07-14. **The resolver-layer sibling (former #241, `b2dc67da`, 0.5.18-alpha.2):** `getLib` and BOTH `getConfig` branches dereferenced `conf.bundlesConfiguration.conf[bundle][env]…` UNCONDITIONALLY — but a detached caller (a bundle `onInitialize`, a cron, a bootstrap `getLib()`) during an ABORTED async Config build observes a PARTIAL Config: `envConf` valid (populated at `config.js:391`), `bundlesConfiguration` + `Config.initialized` undefined (assigned at `:243-250` only after `loadBundlesConfiguration` succeeds; an abort — canonically a fail-closed `${secret:KEY}` resolution throw — returns first). The `reading 'conf'` TypeError then re-threw through the detached-path `throwError` → uncaughtException → SIGKILL, MASKING the real boot error. Grep-PROVEN surface (not whack-a-mole): the ONLY unguarded live consumers were `getLib` + `getConfig` — config.js's own reads are guarded (`if (Config.initialized && self.bundlesConfiguration)`) or during-build, and 14/16 derefs are `.conf` (=== envConf, an alias). Fix: ONE shared `resolveBundlesConf(conf)` (`bundlesConfiguration.conf` when built, else the equivalent aliasing `envConf`, else null) consumed by all three resolvers so the siblings cannot drift, PLUS fail-clean — every catch and the unresolvable-else pass `throwError(…, isFatal=true)` (emerg-log + return, never a bare throw escalating to SIGKILL). Byte-identical on the healthy path. **The partial Config is usually a SYMPTOM** — something upstream aborted the build — so hardening the resolvers UN-MASKS the real, actionable error (e.g. `Secret resolution failed for <KEY>`) instead of hiding it behind a `reading 'conf'` crash. Rule: a global config resolver reachable from a detached / module-load context must accept a partial Config (fall back to the aliasing envConf) and fail clean when neither source is usable. 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).
|
|
963
|
+
236. **`helpers/context.js` `throwError` no longer crashes when called from a DETACHED context (cron/timer/worker/bootstrap) that holds a stale global `router` (0.5.18-alpha.2).** This is the GLOBAL helpers' error path (used by `getLib`/`getConfig`/context bootstrap) — distinct from the controller's `self.throwError(response, code, …)`. `core/router.js:484` stores each request's `{response, next, hasViews, isUsingTemplate}` in a GLOBAL context slot (`setContext('router', …)`) and NEVER clears it, and `throwError` (`context.js:284`) is its SOLE reader — so after any request finishes a stale routerObj lingers: a finished `response` (`headersSent === true`) and a non-callable `next`. A caller outside a live request (a `setTimeout` cron, a worker, a bootstrap-time `getLib()`) then entered the request-response branch and called the stale `next()` → `TypeError: next is not a function`, which additionally MASKED the real error it was reporting (e.g. the actual reason a cron's `getLib()` failed to load its lib — and the `getLib` inner catch even passes `isFatal=true` intending the emerg-log-and-return path, but pre-fix `isFatal` was honored ONLY in the no-router branch, so a stale router defeated it). Restructured into explicit guarded outcomes: render an error response only when a live response is still WRITABLE (`res && !res.headersSent`); hand back to the chain only when `next` is GENUINELY a function (`res && res.headersSent && typeof next == 'function'`); otherwise honor `isFatal` (`console.emerg` + return) or `throw err` — so a detached caller SURFACES the real error instead of crashing on a dead `next`, and the underlying failure becomes visible in the logs. All live-request behaviour is byte-identical (the always-JSON `isUsingTemplate = isUsingTemplate` self-assignment quirk — always `undefined`, so the HTML error-page branch is dead code — was deliberately PRESERVED; fixing it flips template-bundle error responses JSON→HTML, a separate behavioural decision). Because `throwError` is the router context's only reader, making IT robust fully covers all 6 call sites (bootstrap `:72`, getConfig `:437/:457`, getLib `:578/:583/:588`); clearing the router context per-request is a separate, higher-blast-radius hardening not needed for this class. Rule: any framework helper reachable from a non-request context (crons/timers/workers/bootstrap) must not assume a live `req`/`res`/`next` — the global `router` slot may be stale; gate `next()` on `typeof next == 'function'` and never write to a `headersSent` response. Server-side only — NO dist rebuild. Tests: `test/core/context-throwerror.test.js` (source pins + pure-logic replica + a subtract control reproducing the exact `TypeError`). Commit `6b08618d`, established 2026-07-14. **The resolver-layer sibling (former #241, `b2dc67da`, 0.5.18-alpha.2):** `getLib` and BOTH `getConfig` branches dereferenced `conf.bundlesConfiguration.conf[bundle][env]…` UNCONDITIONALLY — but a detached caller (a bundle `onInitialize`, a cron, a bootstrap `getLib()`) during an ABORTED async Config build observes a PARTIAL Config: `envConf` valid (populated at `config.js:391`), `bundlesConfiguration` + `Config.initialized` undefined (assigned at `:243-250` only after `loadBundlesConfiguration` succeeds; an abort — canonically a fail-closed `${secret:KEY}` resolution throw — returns first). The `reading 'conf'` TypeError then re-threw through the detached-path `throwError` → uncaughtException → SIGKILL, MASKING the real boot error. Grep-PROVEN surface (not whack-a-mole): the ONLY unguarded live consumers were `getLib` + `getConfig` — config.js's own reads are guarded (`if (Config.initialized && self.bundlesConfiguration)`) or during-build, and 14/16 derefs are `.conf` (=== envConf, an alias). Fix: ONE shared `resolveBundlesConf(conf)` (`bundlesConfiguration.conf` when built, else the equivalent aliasing `envConf`, else null) consumed by all three resolvers so the siblings cannot drift, PLUS fail-clean — every catch and the unresolvable-else pass `throwError(…, isFatal=true)` (emerg-log + return, never a bare throw escalating to SIGKILL). Byte-identical on the healthy path. **The partial Config is usually a SYMPTOM** — something upstream aborted the build — so hardening the resolvers UN-MASKS the real, actionable error (e.g. `Secret resolution failed for <KEY>`) instead of hiding it behind a `reading 'conf'` crash. Rule: a global config resolver reachable from a detached / module-load context must accept a partial Config (fall back to the aliasing envConf) and fail clean when neither source is usable. 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). **The caller walk (#B695, 0.7.1) — the third sibling.** When no bundle is named, both resolvers used to locate the calling bundle by reading `__stack` frame by frame (one full V8 stack capture per READ) and skipping every frame whose path contains `node_modules`, for EVERY call shape — although `getConfig()` and `getConfig(confName)` always resolve to `ctx.bundle` and discarded the result. Under an npm install every framework file sits under `node_modules/gina`, so a call from framework code (nine framework frames above it) found no frame and threw at `file.replace` on null, BEFORE both `try` blocks; in a checkout the first frame qualifies, which is why no suite run saw it. Now the walk runs only where its result is consumed — `getConfig(<falsy>, confName)` and `getLib(lib)`, and only when `ctx.bundles` is set — captures ONCE through `callerFileSegments(__stack)` (the getter is evaluated IN the resolver, so index 0 stays the resolver's own frame: never move the read into the helper), and a stack with no qualifying frame yields `[]`, the resolver's ordinary no-match path — what a caller outside every bundle already got. Rules: framework code must never locate « the calling bundle » from file paths (the install shape changes the answer) — name the bundle or use `ctx.bundle`; and a `try/catch` around a security setting's read whose fallback is the permissive value turns any exception into a silently disabled control. Tests: `test/core/context-caller-walk-b695.test.js` (source pins; the extracted prologues run against npm-shaped stacks after frozen v0.7.0 copies reproduce the crash).
|
|
964
964
|
|
|
965
965
|
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` (a coercion transform, kept out of the DTO vocabulary — its historical server crash is fixed by #B398) + `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`. **The full DTO family rides this entry (consolidates former #240/#243/#245/#247 — #DTO1c/#DTO2/#DTO3a/#DTO3b, #B110/#B201, #MS4).** **Generators (`bundle:openapi` + `bundle:mcp`, former #240):** 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)` — require → `mod instanceof DtoObject ? mod : mod(dto)` → stamp+register → registry-fallback → null; fail-soft (a broken factory warns+skips). ⚠️ **A bundle DTO file MUST use the FACTORY shape `module.exports = function (dto) { return dto.object({...}, 'Name'); }`, NOT `require('gina').dto`** — the OFFLINE CLI bootstraps 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 (the contextless-require boundary — its full mechanics + the MQ-speaker hang/reconnect record live in #212); `require('gina').dto` works only at request-time in a booted bundle, the factory works in both (measured 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; `param.responseDto` → `outputSchema`. Both un-collapse an inline `validator::{...}` URL-param requirement into a real schema fragment, and #B201 (0.6.3) closed three silently-dropped forms: scalar `"isString": N` → `minLength`, and `isInteger`/`isNumber` digit bounds → a `description` + namespaced `x-gina-digitBounds` `{min?,max?}` — digit bounds constrain the value's STRING-FORM length (a negative sign counts), deliberately NOT mapped to `minimum`/`maximum` (wrong for every negative: digits [2,4] admit [-999,-1] ∪ [10,9999]) nor `minLength`/`maxLength` (string-only keywords a validator ignores on a numeric type). **Response-side emission drops `.exclude()`d fields (#B110):** both response sites emit `toJsonSchema(dialect, { dropExcluded: true })` — an excluded field can never reach the wire, so the 200 content / outputSchema must not advertise it in `properties` NOR `required[]`; request-side keeps the declared shape (the client DOES send it); the drop reads the SHAPE (`_excluded`), never `toRules()`. **#MS4 (0.5.25) — the authorization contract in `bundle:openapi`:** gated routes (`param.requireAuth === true`, non-empty `param.roles`/`param.policy` — the exact runtime predicate; a truthy-STRING requireAuth does not gate, matching the runtime) emit a `401` (+ a `403` only when roles/policy add authorization beyond authentication — role/policy NAMES never reach the spec); when machine-caller auth is EFFECTIVELY configured (`auth.machine.enabled === true` strictly AND callers non-empty OR authenticator set — fail-closed) the spec gains `components.securitySchemes.bearerAuth` + per-operation `security` on gated routes only, never top-level. NO session-cookie scheme (the cookie name is app-owned, unreadable offline). Enabler: the settings.json read switched to `requireJSON` (plain `require` throws on the comment lines real settings carry). **The 422 pipe (former #243, #DTO2 — default-on request validation + response shaping):** a routing.json rule declaring `param.dto` gets its payload validated BEFORE the controller action at BOTH router dispatch sites via `lib.dtoPipe.validateRequestPayload` — a strict NO-OP for any route declaring none. Failure: a clean **422** carrying the engine's localised `fields: {field:{rule:message}}` map. Success: the action gets a COERCED payload ('30'→30, 'true'→true), `.exclude()`d fields dropped, plus `req.dto` = the strict declared-fields-only projection. Placed BEFORE the `reservedActions` loop (a 422 short-circuits the controller — `onReady` never fires) while route middleware has already drained (auth 401 still precedes validation 422). **Resolution is BOOT-time, not lazy:** every `param.dto`/`param.responseDto` is loaded + registered at the `configured` emit, and a request DTO is dry-run `.toRules()`-compiled — a MISSING / broken / `$`-bearing DTO REFUSES the boot (emerg + exit 1) instead of silently disabling validation in production; consequence: a DTO file edit needs a bundle restart, exactly like routing.json. ⚠️ **The pipe MUST inject `''` for required-but-ABSENT fields** — the engine iterates the DATA, not the rules, and `isRequired` fires only on present-but-empty, so an omitted required key otherwise sails through with `isValid() === true` and an EMPTY error map (a silent bypass, measured + subtract-pinned); inject for REQUIRED fields only (an optional absent field would come back as a spurious `''`). ⚠️ **Undeclared keys are PASSED THROUGH, never stripped or rejected** — at dispatch `req[method]` carries the URL params merged alongside the body, so URL params ARE undeclared keys and stripping/422-ing them would break every parameterised route; `additionalProperties: false` stays an OpenAPI statement, not a runtime gate. Note `req.body` and `req[method]` are DIFFERENT objects at dispatch — the pipe coerces the declared keys `req.body` already carries, in place, and never injects a URL param into it. **Response DTO:** `param.responseDto` shapes the outgoing JSON at `controller.render-json.js` — ONE transform above the single `JSON.stringify` that feeds every body-write branch AND the cache write, so the wire body and the cached body are shaped identically; strip-only, 2xx-gated (an error payload is never mangled); `DtoObject.apply()` keeps the `__gina*` Inspector sidecars and drops `.exclude()`d fields — `.exclude()` finally means "never serialise this". **TypeScript projection (former #245, #DTO3a — `gina bundle:types` and the TWO-type rule):** walks `dtos/<Name>.js` and emits `<bundle>/dtos/index.d.ts`. ⚠️ **Each DTO emits TWO types, measured not stylistic:** an `.exclude()`d field IS in the canonical JSON Schema but ABSENT from what the server holds (the engine drops it and `apply()` deletes it), so a single schema-derived type would LIE about `req.dto` — hence `<Name>` = the DECLARED shape and `<Name>Projected` = `Omit<Name, …excluded>` (what the action reads); a plain alias when nothing is excluded. ⚠️ The scalar mapping describes the COERCED payload, not the raw wire string — and **`date` → `string`, NOT `Date`** (`isDate` writes a `yyyy-mm-dd` string into the payload — it no longer shifts the value; see #209 / #B558). ⚠️ The excluded set is read off the DTO's SHAPE, never `toRules()` — `toRules()` THROWS on an authored `$` (see the value-range block above), so a currency-style DTO can never compile to rules yet still documents; the emitter must be TOTAL where `toRules()` is not. The emit is pure + deterministic (drift-checkable by re-running in memory — the generator→artifact→drift-test triad); a DTO that fails to load ABORTS the command (a type surface minus a broken DTO would ship an incomplete contract silently). **Published declarations + the two-part types gate (former #247, #DTO3b):** the shipped `types/*.d.ts` compiled clean at strict while wrong 15 ways (headline: `types/index.d.ts` declared NO value, so `gina.lib` was a type error for every consumer; plus lies that typecheck then crash — `Gna extends EventEmitter` on a plain object literal, fictional `String.prototype.ltrim`, `export class SuperController` the main entry never exports). Repair shape: **namespace-as-module** — `declare namespace gina { ... } export = gina` — the only shape describing BOTH `import gina = require('gina')` and the ESM default import; conditionally-assigned members are typed `| undefined` to force narrowing; `GinaRequest<TDto>` types `req.dto` and the method payloads as `TDto & Record<string, any>` (the intersection preserves declared field types while URL params stay reachable). The gate is TWO-part because a compile catches neither lie class: (a) `script/check_types_consumer.js` compiles a consumer fixture BY PACKAGE NAME through the exports map (`moduleResolution: node16`; a by-path probe is structurally blind to a missing `export =`) with `skipLibCheck: false`, `@ts-expect-error` tripwires, and a MUST-FAIL control program proving the instrument can fire; (b) `test/lib/types-runtime-parity.test.js` reflects the REAL runtime (lib registry keys, a real `createTestInstance()`, the UNANCHORED `gna.<name> =` source enumeration — a line-anchored variant missed 22 indented members — and the injected-globals diff) and diffs it two-way against the declarations, every deliberate exception carrying its reason inline. Wired as `npm run types:check` + a CI step. Tests: `dto.test.js` (37) / `dto-pipe.test.js` (26) / `dto-types.test.js` (38) + `dto-types-drift.test.js` / `bundle-openapi.test.js` / `bundle-mcp.test.js` / `render-json.test.js §06`; the pipe live-verified on a real daemonless bundle (coercion + URL-param survival + exclude + `req.dto`; 422 on bad input AND an omitted required key; a no-DTO route untouched; the boot refusing a missing / `$`-bearing DTO with a valid-DTO control). Server-side/CLI-only — NO dist rebuild.
|
|
966
966
|
|
|
@@ -1016,7 +1016,7 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
1016
1016
|
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).
|
|
1017
1017
|
|
|
1018
1018
|
276. **Worker-global proxy-context poisoning by port-less internal calls — the `getUrl`/`url` template filters, the cross-bundle redirect-by-URL target and the error-fallback redirect now prefer the emitting request's own classification; opt-in `server.proxy.requireForwardedHeaders` makes classification deterministic (#B152).** A request whose inbound Host carries no `:port` is classified reverse-proxied (the first arm of the per-request classification, both engines) and REFRESHES `process.gina.PROXY_HOST`/`PROXY_HOSTNAME` — but a port-less Host is also what an internal call addressed by service/DNS name carries (a container health probe on an app route — only `/_gina/health/check` itself, `$`-anchored, bypasses the classification block — a mesh hop, a sibling-bundle request), so ONE such call repointed every later worker-global read at the internal host: both `getUrl` filter branches (the path/bundle-override `hostname` composition AND the post-`getRoute` `proxy_hostname` override feeding `toUrl()`), the redirect-by-URL `getRoute(path).toUrl()` branch, and `throwError`'s route-object fallback. On a proxy-less fresh worker the same call CREATES proxy mode permanently (the composite latch arms once the global exists). Fixed server-side, NO dist rebuild (`getUrl` is not browser-bundled — the dist's `getUrl` hits are `getUrlProps`, a substring artifact): all four readers prefer THIS request's per-request slots (`req._ginaIsProxyHost` strict-`=== true` + `req._ginaProxyHostname`) over the worker-global, which stays the fallback for renders with no request of their own — a raw victim resolves the config host despite the latch, a proxied victim emits its own host, a slot-less caller is byte-identical; `core/router.js` fills the slots when ABSENT so the Express engine (which never set them) gets per-request truth engine-agnostically, isaac's earlier identical classification always winning. The opt-in key (settings.json + published schema; strict boolean, fail-safe false, boot-resolved once, `console.info` when armed) disables the port-less heuristic so ONLY X-Forwarded-Host classifies as proxied — the one mechanism that also protects req-less renders; enable it only behind a front proxy that always sends X-Forwarded-Host. Two mechanism corrections vs the field report that surfaced this: `PROXY_PORT` is boot-static (never request-refreshed; the boot scheme-switch forces it to 80/443, clobbering any proxy.json-hostname port), so a nonstandard port in an emitted URL comes from the victim request's OWN normalized `headers.port`/`[':port']`, never the poisoner; and moving container probes off `/_gina/health/check` onto app routes is what acquires the bug — nothing signals it. Pickup: restart (server-side only). Tests: `test/core/proxy-request-scope.test.js` (44, incl. a real `getRoute`/`toUrl` subtract reproducing the internal-host emission pre-fix). **Falsy proxy-host state degrades the URL builder, never crashes it (former #291, #B168, 0.6.0):** server-side `getRoute()` resolves `route.proxy_hostname` from the worker global with an envConf fallback, and BOTH are framework-produced falsy states (the global is boot-set only from a proxy config carrying a hostname and otherwise first written by a proxied request; the envConf fallback is deliberately written null for a direct-classified render) — with the worker-wide proxied latch true and both unset, the unguarded rewrite threw `TypeError: Cannot read properties of null (reading 'replace')` on EVERY server-side `getRoute()` while the state lasted: URL building for mail/workers, redirects and health checks alike (observed live: 72 identical failures through a readiness probe, healing in-process once a later render rewrote the envConf fallback). The truthiness guard falls back to the route's direct hostname AND flips `route.isProxyHost` false — `toUrl()` keys on that flag, so a bare skip would stringify the unset value into the emitted URL, converting a loud crash into silently wrong links; a once-per-process warning names the degraded state. The `url` template filters hold `getRoute()`'s own resolution and degrade only when nothing resolved anywhere (their per-request override used to re-force the flag with an unresolved value in slot-less renders, undoing the fix — and replacing a usable envConf-derived value with an unset one); the non-proxied branch's gate requires TRUTHY (the old `typeof` admitted a defined-but-falsy global — typeof null is 'object'). Browser bundle changed (`lib/routing` ships in it) with zero client behavior change — the client arm resolves from `window.location`, which cannot be unset. Rule: a framework-produced falsy state must DEGRADE the builder, and the degrade must flip the flag downstream code keys on. **#B367 (0.6.8) — those same headers are now VALIDATED WHERE THEY ARE READ, because until 0.6.8 they reached the browser unescaped and were a reflected-XSS vector.** `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Prefix` and the caller's own `Host`/`:authority` feed `page.environment.hostname` / `.webroot` / `.proxyHost` / `.proxyHostname`, which the client bootstrap (`gina.onload.min.js`) carries in SINGLE-QUOTED JS string literals (`hostname:'{{ … }}'`, `window.__ginaWebroot='{{ … }}'`). The substitution is `whisper(dic, layout, /\{{ ([a-zA-Z.]+) \}}/g)` — whisper's rule branch is `replaceable.replace(rule, (s,key) => dictionary[key] || s)`, a RAW splice that escapes nothing — and the loader is injected AFTER template compilation precisely so the engine never sees those tokens. So a single quote in one header closed the literal and executed attacker script on every rendered page, unauthenticated (reproduced live over HTTP on both the host and prefix vectors). The guard is at INGEST, not emission, and that choice is load-bearing: whisper runs over the WHOLE layout, so the same dictionary also feeds HTML contexts where a JS-string escape would emit literal backslashes; and the sibling values already safe (`forms`, `routing`, `validatorLabels`) use `encodeRFC5987ValueChars` + a client `decodeURIComponent`, so extending that pattern would have changed the browser bundle and billed every consumer a re-bake for a server-side flaw. A host must be `name[:port]` or a bracketed IPv6 literal (`/^[A-Za-z0-9._:\[\]-]+$/`, ≤255), a forwarded scheme must be exactly `http`/`https`, a prefix must be URL-path characters; anything else is REFUSED and the request falls back to the bundle's configured host/webroot as if the header were absent — including the classification itself, so a malformed `X-Forwarded-Host` no longer marks the request proxied. The guard is TWINNED in `core/server.isaac.js` and `core/router.js` (the same deliberate twinning as the classification above) and a test pins the two character classes EQUAL, because a drift makes one engine exploitable again. Known consequence: a comma-separated `X-Forwarded-Host` (chained proxies) now fails the guard and falls back — pre-fix it produced `scheme://a.example, b.example`, i.e. already garbage; splitting the list is deliberately NOT done, since element 0 is the original client's own value and taking it would reopen the vector, and any other element needs a trusted-hop count the framework does not have. Server-side only ⇒ pickup is a bundle RESTART, no re-bake. ⚠️ **Field reachability (consumer-reported 2026-08-15, and it INVALIDATED our own published check): `X-Forwarded-Prefix` is the header that actually exposes people, and the obvious audit question misses it.** We had told operators to ask whether their proxy SETS the `X-Forwarded-*` headers from its own knowledge or FORWARDS what the client sent. That question is a FALSE-NEGATIVE GENERATOR: a conscientious edge config sets `Host`/`X-Forwarded-Host`/`X-Forwarded-Proto`/`X-Forwarded-For` and never MENTIONS `X-Forwarded-Prefix` (a mount path is a concern most deployments never set deliberately), and nginx forwards any header it does not explicitly override — so the prefix travels verbatim while the other three are correctly replaced, and the operator answers "we overwrite them, we're fine" and is WRONG. Reported on a two-layer nginx topology (both layers setting Host + proto, outer setting X-Forwarded-Host, NEITHER naming the prefix — measured 0 with firing controls). **Verified first-hand here at the `v0.6.7` tag: the prefix block's only enclosing scope is `server.on('request', …)` — there is NO proxy-classification gate on it at all**, so the prefix is accepted whether or not the request counts as proxied, and correctly overriding `X-Forwarded-Host` cannot close it; the gap is sufficient alone. Ask the question PER HEADER ("is `X-Forwarded-Prefix` named in this config?") at EVERY layer, never as a blanket `X-Forwarded-*` question. Rule: a request header that will be interpolated into a client-side script is untrusted input at the READ site, and the cheapest correct place to reject it is where it enters — not where it is emitted. Second rule, from the reachability miss: when you hand operators a check for whether a flaw can reach them, make it name EACH input separately — a check phrased over a family of headers silently assumes the proxy treats the family uniformly, and proxies do not. **#B502 (0.6.28) — the LAST req-less reader now resolves per request, and the router twin stops rewriting the worker-global from direct requests.** Everything above re-pointed the framework's OWN callers; an APPLICATION-level `getRoute(…).toUrl()` — the form this file documents — still resolved from `getContext('isProxyHost')` (a latch that arms the moment any request writes `process.gina.PROXY_HOSTNAME` and never resets) plus that global. Measured on isolated two-bundle scenes: after ONE port-less-Host request, every later DIRECT request's `toUrl()` lost its port on an http/1.1 bundle (`http://127.0.0.1/web/echo/3` for a bundle on `localhost:9940` — isaac strips the port from `headers.host` BEFORE the router twin re-classifies, so every direct isaac request re-wrote the globals from the stripped host) and, on an http/2 + https bundle, emitted the LAST port-less client's host verbatim (`http://evil.example/…` after one `Host: evil.example` — attacker-choosable, and the shape a field report described). Fix in three parts: `server.js handle()` enters `process.gina._reqALS` on EVERY request in every log mode (#M12b had it JSON-only) with a `proxy` slot; `core/router.js` fills the slot from THIS request's stamps right after the engine-agnostic classification; `lib/routing getRoute()` reads the store FIRST and keeps the latch + worker-global only for req-less callers (boot, CLI, cron — no store). The router twin's `proxyReqIsProxied` now defers to an existing isaac stamp (the pre-strip truth), so direct requests no longer rewrite the worker-global; isaac's port-less scheme chain matches the twin's (X-Forwarded-Proto, then PROXY_SCHEME, then the bundle scheme). Both logger readers stay JSON-gated, so text mode gains only the ALS enter. `requireForwardedHeaders` closes the port-less-Host path only on earlier versions — the classification's `X-Forwarded-Host` term is ungated on both engines (`server.isaac.js:2188-2192`, router twin), so a client with direct reach could still supply the host; the complete interim was a proxy overriding both headers. `lib/routing` is browser-bundled ⇒ pickup is a bundle RESTART **and a re-bake** — the only dist hunks are the server branch's three lines and the client branch is byte-identical, so the re-bake keeps bundle-freshness honest and changes no client behaviour. Residual, by design: `PROXY_SCHEME` is the PROJECT's `def_scheme` (gna.js), so with no `X-Forwarded-Proto` an https bundle in an http-default project still stamps `http://` on both twins (#B506, LOW). Tests: `test/core/proxy-context-b502.test.js` (source pins on all three parts + a real-ALS replica of the resolution rule; red-first 6/4 on the pre-change source). **#B511 (0.6.29) — a path-form `getUrl` route containing `@` no longer 500s the render (both engines).** The swig and nunjucks `getUrl` filters split any `@`-bearing route as `rule@bundle` BEFORE testing whether it was a path, so `'/assets/img/common/header@2x.png' | getUrl` — the retina idiom the filter's own comment cites — became rule `/assets/img/common/header` @ bundle `2x.png`, failed the bundle lookup and called `throwError(500)` mid-template; `throwError` RENDERS (it never throws), the site is outside the filter's routing try, and nothing returns after it, so the page answered 500 while the action's own render was discarded by the released-response guard (measured live on an isolated prod boot: 500 → 200 with the extension intact after the fix, on swig and on a bare-template nunjucks scene). The guard adds one term to the split's condition — a route starting with `/` is a path, never a rule reference — at both sites. Unchanged: `'rule@bundle'` still splits, a passed base still wins over the in-string form, and a path that needs another bundle's host passes it as the base argument (`| getUrl(null, 'web')`) — the only form one measured consumer corpus used (zero bare calls). Disclosed consequence: a path-form route cannot name a bundle in-string. Neither filter lib is browser-bundled (`build.json` 0/0) ⇒ pickup = bundle RESTART, no re-bake. Tests: `test/lib/geturl-b511.test.js` (per-engine source pins incl. the guard's position BEFORE the path branch — the ordering IS the defect — and a predicate lifted from each engine's shipped condition, with the pre-fix decision as the subtract control). Generalises: a soft-failing filter can still hard-fail through a render-side `throwError` reached before its own try — pin the ORDER of the guards, not just their presence.
|
|
1019
|
-
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.
|
|
1019
|
+
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. Since 0.7.1 the isaac PRE-ROUTING read runs only while `server.cache.enable` is on (the phase-2 per-request trims, slice H) — parity with the writers and the express-side read; an entry left on disk by a run that had caching enabled is no longer served while it is disabled.
|
|
1020
1020
|
|
|
1021
1021
|
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.
|
|
1022
1022
|
|
|
@@ -1026,7 +1026,7 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
1026
1026
|
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.
|
|
1027
1027
|
|
|
1028
1028
|
|
|
1029
|
-
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" / "ISO 27001 certified" — and no framework can be: compliance is achieved by an ORGANIZATION, through a QSA assessment, an independent auditor's attestation, a documented risk analysis plus Business Associate Agreements, or an accredited body's audit of its information security management system (ISO/IEC 27001). 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), 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. Application-level rate limiting SHIPPED in 0.6.13 (#MS6: opt-in identified-caller quotas counted in a kv namespace, `429` + `Retry-After` + the draft `RateLimit` fields, per-route overrides; anonymous flood control stays at the edge). ISO/IEC 27001:2022 Annex A cites (2022 numbering, 93 controls) sit beside the PCI-DSS / SOC 2 / HIPAA ones on every row — secure authentication A.8.5, access restriction A.5.15/A.8.3, logging A.8.15, application security + secure coding A.8.26/A.8.28, configuration + authentication information A.8.9/A.5.17, cryptography in transit A.8.24, rate limiting A.8.6 — and the guide names the supplier-side evidence for A.5.19–A.5.22 / A.8.8 (private vulnerability reporting + published CVSS-scored GHSA advisories, Dependabot/OSV/Socket scans, staged 2FA-approved npm publishing; no per-release SBOM
|
|
1029
|
+
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" / "ISO 27001 certified" — and no framework can be: compliance is achieved by an ORGANIZATION, through a QSA assessment, an independent auditor's attestation, a documented risk analysis plus Business Associate Agreements, or an accredited body's audit of its information security management system (ISO/IEC 27001). 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), 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. Application-level rate limiting SHIPPED in 0.6.13 (#MS6: opt-in identified-caller quotas counted in a kv namespace, `429` + `Retry-After` + the draft `RateLimit` fields, per-route overrides; anonymous flood control stays at the edge). ISO/IEC 27001:2022 Annex A cites (2022 numbering, 93 controls) sit beside the PCI-DSS / SOC 2 / HIPAA ones on every row — secure authentication A.8.5, access restriction A.5.15/A.8.3, logging A.8.15, application security + secure coding A.8.26/A.8.28, configuration + authentication information A.8.9/A.5.17, cryptography in transit A.8.24, rate limiting A.8.6 — and the guide names the supplier-side evidence for A.5.19–A.5.22 / A.8.8 (private vulnerability reporting + published CVSS-scored GHSA advisories, Dependabot/OSV/Socket scans, staged 2FA-approved npm publishing, and an OpenSSF Scorecard published on scorecard.dev since 2026-09-27; no per-release SBOM yet). 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.
|
|
1030
1030
|
|
|
1031
1031
|
|
|
1032
1032
|
|
|
@@ -1044,9 +1044,9 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
1044
1044
|
|
|
1045
1045
|
|
|
1046
1046
|
|
|
1047
|
-
298. **Render/request-path deep-clone & id-mint reduction (#P39 slice 1, 0.6.2; slice 3 = #P40, the `getConfig()` copy-on-write view, below) — the filter-factory options travel by REFERENCE, locale lookups are memoized, `conf.locales` is a lazy per-request accessor, and `lib/uuid` right-sizes its entropy batch.** The CPU-profiling baseline convicted per-request copy/id overhead as ~60% of render-arm CPU, 98.7% of the clone time in three caller-attributed paths; all three are gone. (1) `controller.render-swig.js` / `controller.render-nunjucks.js` hand `local.options` to the filter factory UN-cloned, and `lib/swig-filters` / `lib/nunjucks-filters` `getInstance()` stashes that wrapper by reference — the singleton's `_options` has no writer beyond the stash itself (every filter only reads it; the `getUrl`/`getWebroot` merge fills a fresh `{}` target), the request path already shares `conf.routing`/`conf.reverseRouting`/`conf.forms` by reference, and `req`/`res` always passed by reference anyway (the cloner bails on non-plain constructors) — so reference-passing adds no new mutation-visibility class and the stash-then-await interleave window is byte-identical to the cloned era. Anything that MUTATES what it reads from the filter context must clone its own copy first (the pre-existing contract; `getRoute()` already does). (2) `setOptions` no longer builds two Collections per request over the boot-static region sets (each was a deep copy of ~500 nested records plus one 16-char id minted per record ≈ 750 webcrypto calls, paid by EVERY request in a views-bearing bundle, render or not): a module-level per-culture memo answers the language → region-set and country → row lookups; `conf.locale` is a per-request deep copy of the ONE resolved row (isolating the `.date` write and template mutations); `conf.locales` is a LAZY self-replacing accessor — the request's own deep copy materializes on first read (sole framework reader: `self.getLocales()`), so the common request pays nothing while whole-conf clones and serializers materialize it transparently, and assignment writes through. (3) `lib/uuid` sizes its `getRandomValues` batch to the REQUESTED id length (the fixed 7-byte batch was sized for the 4-char default, so `uuid(16)` — the form `lib/collection` mints per item — needed ~3 webcrypto calls; now 1; each call pays a full randomFillSync regardless of byte count, so call count is the cost). (4) The cloner's dead per-recursion `Object.keys` allocation is removed. Measured A/B on the profiling harness (fresh runs both sides, same knobs): render wall 32,186 → 7,268 ms for 3000 requests = 4.4× throughput (10.7 → 2.4 ms/render), deep-clone share 50.31% → 1.81%, GC 2,522 → 137 ms, randomFillSync out of the top-15 frames on both arms, upload-arm clone floor 253 → 11 ms — the pre-stated ≈2× ceiling was beaten because allocation-pressure knock-on (GC, locality) fell with the clones. `lib/uuid` is AMD-bundled, so the browser bundle changed: consumer pickup = restart AND per-bundle re-bake. `Collection`'s own eager per-item `_uuid` minting is UNCHANGED (its only consumers are collection-internal; `toRaw()` strips). **Slice 3 — `getConfig()` no longer deep-clones (#P40, 0.6.x): both forms return a per-call COPY-ON-WRITE VIEW (`lib/conf-view`, registered as `lib.confView`).** Reads pass through to the shared conf at no copy cost, a write lands in the view's private overlay (never in the live conf, never visible to another call), and the first enumeration of a node (`Object.keys` / `JSON.stringify` / `for…in` / spread / `JSON.clone`) materialises that subtree once into a plain private copy; identity is stable, and two paths to one shared object give one view node, so `conf.settings === conf.content.settings` is PRESERVED where the clone silently split it; non-plain values pass through by reference exactly as the cloner did; a frozen child is copied once into the overlay, a frozen root yields a plain deep copy; an accessor runs against its own object (the lazy `locales` getter keeps working). Measured on the profiling harness — a route behind a middleware pair whose constructors each read `this.getConfig()`, the shape a consumer production profile convicted — 14,719 → 1,884 ms for 3,000 requests, deep-clone share 72.9 % → 3.1 % (the bare form cloned a 445 KB resolved conf on a minimal scaffold, megabytes on a large bundle; middleware construction itself was ACQUITTED by a `json-mw-noconf` split arm). Three things a clone allowed do not work on an UN-enumerated node: `structuredClone(view)` throws `DataCloneError`; `Object.freeze` / `seal` throw `TypeError` (enumerate the parent first — the node is then a plain copy); `console.log` / `util.inspect` render the shared values rather than the overlay (property reads and `JSON.stringify` are truthful). One divergence, bounded by the proxy invariants: a non-configurable own property of the shared tree refuses delete / redefine, and a write when it is also non-writable (an array's `length` takes writes; no conf node carries such a property). Opt-out per bundle: `settings.json > controller.getConfig.mode: "clone"` (read per call) restores the historical deep clone — the two `JSON.clone(...)` literals stay in the clone branch. Rule for a caller: a view is yours to read, mutate and serialise; enumerate before you freeze; opt into `clone` only for `structuredClone`. Server-side only ⇒ restart, no re-bake. Tests: `test/lib/conf-view.test.js` (30 arms on the shipped module incl. a subtract control) + `test/core/controller-getconfig-view.test.js` (comment-stripped pins incl. the extraction-boundary control; arms compiled from the source with the real lib; a real instance through the registry; red-first 12 red / 12 green against the pre-#P40 bytes). Remaining clone sites (hot route-matching-loop clones, `getRoute`-per-`getUrl` filter call, `getParams` per-call clone, cssColl/jsColl construction, async-delegate store clones) stay measure-gated for a future profile.
|
|
1047
|
+
298. **Render/request-path deep-clone & id-mint reduction (#P39 slice 1, 0.6.2; slice 3 = #P40, the `getConfig()` copy-on-write view, below) — the filter-factory options travel by REFERENCE, locale lookups are memoized, `conf.locales` is a lazy per-request accessor, and `lib/uuid` right-sizes its entropy batch.** The CPU-profiling baseline convicted per-request copy/id overhead as ~60% of render-arm CPU, 98.7% of the clone time in three caller-attributed paths; all three are gone. (1) `controller.render-swig.js` / `controller.render-nunjucks.js` hand `local.options` to the filter factory UN-cloned, and `lib/swig-filters` / `lib/nunjucks-filters` `getInstance()` stashes that wrapper by reference — the singleton's `_options` has no writer beyond the stash itself (every filter only reads it; the `getUrl`/`getWebroot` merge fills a fresh `{}` target), the request path already shares `conf.routing`/`conf.reverseRouting`/`conf.forms` by reference, and `req`/`res` always passed by reference anyway (the cloner bails on non-plain constructors) — so reference-passing adds no new mutation-visibility class and the stash-then-await interleave window is byte-identical to the cloned era. Anything that MUTATES what it reads from the filter context must clone its own copy first (the pre-existing contract; `getRoute()` already does). (2) `setOptions` no longer builds two Collections per request over the boot-static region sets (each was a deep copy of ~500 nested records plus one 16-char id minted per record ≈ 750 webcrypto calls, paid by EVERY request in a views-bearing bundle, render or not): a module-level per-culture memo answers the language → region-set and country → row lookups; `conf.locale` is a per-request deep copy of the ONE resolved row (isolating the `.date` write and template mutations); `conf.locales` is a LAZY self-replacing accessor — the request's own deep copy materializes on first read (sole framework reader: `self.getLocales()`), so the common request pays nothing while whole-conf clones and serializers materialize it transparently, and assignment writes through. (3) `lib/uuid` sizes its `getRandomValues` batch to the REQUESTED id length (the fixed 7-byte batch was sized for the 4-char default, so `uuid(16)` — the form `lib/collection` mints per item — needed ~3 webcrypto calls; now 1; each call pays a full randomFillSync regardless of byte count, so call count is the cost). (4) The cloner's dead per-recursion `Object.keys` allocation is removed. Measured A/B on the profiling harness (fresh runs both sides, same knobs): render wall 32,186 → 7,268 ms for 3000 requests = 4.4× throughput (10.7 → 2.4 ms/render), deep-clone share 50.31% → 1.81%, GC 2,522 → 137 ms, randomFillSync out of the top-15 frames on both arms, upload-arm clone floor 253 → 11 ms — the pre-stated ≈2× ceiling was beaten because allocation-pressure knock-on (GC, locality) fell with the clones. `lib/uuid` is AMD-bundled, so the browser bundle changed: consumer pickup = restart AND per-bundle re-bake. `Collection`'s own eager per-item `_uuid` minting is UNCHANGED (its only consumers are collection-internal; `toRaw()` strips). **Slice 3 — `getConfig()` no longer deep-clones (#P40, 0.6.x): both forms return a per-call COPY-ON-WRITE VIEW (`lib/conf-view`, registered as `lib.confView`).** Reads pass through to the shared conf at no copy cost, a write lands in the view's private overlay (never in the live conf, never visible to another call), and the first enumeration of a node (`Object.keys` / `JSON.stringify` / `for…in` / spread / `JSON.clone`) materialises that subtree once into a plain private copy; identity is stable, and two paths to one shared object give one view node, so `conf.settings === conf.content.settings` is PRESERVED where the clone silently split it; non-plain values pass through by reference exactly as the cloner did; a frozen child is copied once into the overlay, a frozen root yields a plain deep copy; an accessor runs against its own object (the lazy `locales` getter keeps working). Measured on the profiling harness — a route behind a middleware pair whose constructors each read `this.getConfig()`, the shape a consumer production profile convicted — 14,719 → 1,884 ms for 3,000 requests, deep-clone share 72.9 % → 3.1 % (the bare form cloned a 445 KB resolved conf on a minimal scaffold, megabytes on a large bundle; middleware construction itself was ACQUITTED by a `json-mw-noconf` split arm). Three things a clone allowed do not work on an UN-enumerated node: `structuredClone(view)` throws `DataCloneError`; `Object.freeze` / `seal` throw `TypeError` (enumerate the parent first — the node is then a plain copy); `console.log` / `util.inspect` render the shared values rather than the overlay (property reads and `JSON.stringify` are truthful). One divergence, bounded by the proxy invariants: a non-configurable own property of the shared tree refuses delete / redefine, and a write when it is also non-writable (an array's `length` takes writes; no conf node carries such a property). Opt-out per bundle: `settings.json > controller.getConfig.mode: "clone"` (read per call) restores the historical deep clone — the two `JSON.clone(...)` literals stay in the clone branch. Rule for a caller: a view is yours to read, mutate and serialise; enumerate before you freeze; opt into `clone` only for `structuredClone`. Server-side only ⇒ restart, no re-bake. Tests: `test/lib/conf-view.test.js` (30 arms on the shipped module incl. a subtract control) + `test/core/controller-getconfig-view.test.js` (comment-stripped pins incl. the extraction-boundary control; arms compiled from the source with the real lib; a real instance through the registry; red-first 12 red / 12 green against the pre-#P40 bytes). Remaining clone sites (hot route-matching-loop clones, `getRoute`-per-`getUrl` filter call, `getParams` per-call clone, cssColl/jsColl construction, async-delegate store clones) stay measure-gated for a future profile. **Phase-2 per-request trims (0.7.1; measured on the json profile scene at 281–556 µs busy per request depending on host load — read the paired deltas, never absolutes):** (F) `helpers/context.js` binds `lib/merge` once at module scope — `setContext()` and the global `getConfig()` resolved it with a relative `require` on EVERY call (3 µs and one `Module._resolveFilename` each, two calls per routed request; Node's relative-resolve cache never short-circuits the shape, because a production boot loads the module through `lib/logger` first and a cache hit never primes the helpers-directory pair). (G) `core/router.js` probes a bundle's `controllers/setup.js` once per bundle in production — `resolveSetupFile()`, memoized by bundle name; dev keeps the per-request probe its eviction depends on — where the per-request probe cost an `accessSync` throw plus an `lstatSync` throw whenever the file was absent. (H) `core/server.isaac.js` looks the output cache up only while `server.cache.enable` is on (every writer already refused to store while it is off, and `server.js`'s engine-agnostic read gated on the same flag): the production-unconditional lookup cost two `existsSync()` per GET and could serve an entry left on disk by an earlier run that had the cache enabled. (#B703) `helpers/path.js` keeps no path registry: `helpers/index.js` calls `PathHelper()` WITHOUT `new`, so in that sloppy-mode constructor `this.paths = []` was `global.paths`, which every `_()` extended with each distinct normalized path for the process lifetime (two per distinct URL through the output cache's file paths, query string included) and which every `_()` and every PathObject `toString()` scanned with `indexOf` (103 µs per unseen path at ~52k entries, measured); nothing read it. `toString()` and `toUnixStyle()` return the value exactly as before; `toWin32Style()` now converts on every call, where the registry returned a value it had not recorded — one whose trailing separator `mkdir()`/`rm()`/`isValidPath()` stripped — with forward slashes. Trap: a helper invoked without `new` writes its `this.<field>` onto the global object, so read the call site before treating a `this.` field as per-instance state. (E) The three per-request Config resolutions — `resolveRouteConfig` (`core/router.js`), `hasViews` and `loadBundleConfiguration` (`core/server.js`) — read `Config.instance` once `Config.initialized` is set: each `new Config().getInstance()` built a throwaway instance only to hand back the singleton's envConf (or the singleton) and re-set Env/Scope/Host on it to the values they already held (getInstance() runs once at boot). The old calls stay as the fallback — a worker's context-merged instance leaves `Config.initialized` unset, and #B542's refusal after an aborted init still surfaces — and `hasViews` consults its per-bundle memo before resolving anything. (B) `setOptions`' page.environment block — which runs on EVERY routed request of a bundle with views, JSON routes included — computes three per-process values once: the encoded `page.environment.forms` export is memoized in a module-scope WeakMap keyed by the forms catalog object (the catalog is loaded once per bundle and the framework never writes it — the validator and `getFormsRules()` work on clones, `getConfig()` hands out copy-on-write views; re-encoding it cost ~5 µs per KB, measured 587 µs per request on a 118 KB catalog vs 0.008 µs for the hit), the `memory allocated` heap-limit label is computed on the first render (it cost a `require('v8')` + getHeapStatistics() per request), and one `isoDateTime` stamp feeds both `page.environment.date.now` and the locale's `date.now`. The forms lookup sits behind a `typeof` guard so the block still runs when a harness compiles it on its own; `page.forms` holds the catalog by reference, so render data that deep-fills it no longer reaches later requests' export. (D1) `set()` — the dotted-path render-data setter, ~60 calls per routed request of a bundle with views — tests for a dot with `name.indexOf('.') !== -1` and splits with `name.split('.')` instead of `/\./.test()` and `split(/\./g)`: the same answers for every string (`split` ignores the `g` flag), −4.8 µs per request measured in the profile scene; `test/core/controller-set-path.test.js`'s differential against the frozen pre-#P39 `set()` keeps certifying the behaviour. (A) `lib/inherits`' composed constructor copies nothing onto the instance: no own `prototype` property (it was enumerable — visible to `Object.keys()` / `JSON.stringify()`), no `this.prototype.name = this.name` stamp onto a SHARED prototype per `new` (in a nested chain it wrote the child's name onto the PARENT's prototype), and no `for…in` own-copy of the parent prototype's falsy members (EventEmitter re-sets its three as own anyway); `if (this)`, the `name` default, `b.apply` then `cache.apply` are kept, so a call without `new` (the validator's is-alias) still works. Measured censuses: 0 readers of an instance's own `prototype` and 0 falsy prototype data props in the framework or a large consumer; entity names still resolve because every connector stamps the COMPOSED class's prototype after `inherits()`. Browser-bundled: prod dist rebuild, consumer pickup = restart AND re-bake. −13 µs per request at the design scale. (C2) A bundle's resolved env configuration stays in V8 fast mode: `%HasFastProperties` probes along `loadBundleConfig` found TWO dictionary triggers — `merge(files, conf[bundle][env])` (the env keys poured into the non-empty `files` target by keyed stores) and, masked until that one is undone, `delete conf[bundle][env].tmpSettingFileContent` (a delete of a non-last property). Only the final shape matters at request time, so the delete is a rebuild without the key (object rest) that re-points `files` and `conf[bundle][env]` together — they are one object from the merge until `files = whisper(…)`, and only named stores follow — BEFORE `secrets.resolve()`, which keys its resolved-paths WeakMap by this very object. The router's per-request `Object.assign({}, conf)` fell from ~20 to ~0.2 µs in the profile scene. Rule: a keyed store past ~17 keys into a non-empty object, or a `delete` of a non-last key, puts an object in dictionary mode for good — rebuild it once (spread / rest) after the last structural change, never per request.
|
|
1048
1048
|
299. **lib/math checkSum helpers - dispatch & serialization contract**: `checkSumSync(filenameOrData, algorithm, encoding)` (defaults md5/hex) accepts a string, object, or array; objects/arrays are serialized first - a plain object becomes sorted `key:value` pairs joined with commas (function properties skipped), an array becomes the JSON of a sorted copy (order-insensitive; the input array is never mutated). A string ending in an extension shape (a dot + 1-10 alphanumerics) is PROBED as a filename, and the file branch is taken only when the path resolves to an existing regular file (stat-gated): a stat miss (no such entry, name too long, not-a-directory path, NUL-carrying string) or an exists-but-directory entry falls through to hashing the input as DATA, so serialized records ending in `.com`/`.net`/`.pdf` return checksums instead of throwing ENOENT/ENAMETOOLONG (#B209; until 0.6.2 they threw, with the caller's data named as a path in the error), while an EXISTING unreadable file (EACCES) still throws. Until 0.6.2 every ARRAY input also collapsed to the checksum of the empty string - all arrays collided at one constant hash - and sort() reordered the caller's array in place (#B208, fixed alongside; previously stored array checksums were the degenerate constant and change by construction). The wide probe shape is #B210 (fixed alongside): before it only dot+3-lowercase tails fired, so paths like `file.js`, `file.json`, or `FILE.TXT` hashed as PATH STRINGS - stored sums for such paths change on upgrade (they never tracked content); an extension-LESS path (`Makefile`, `LICENSE`) still hashes as a data string, never read from disk - pass file content (or an extension-bearing path) when you mean the file. The async sibling `checkSum(filename, algorithm, encoding, isCheckingFromData, cb)` routes to the file branch on ANY dot in the input unless `isCheckingFromData` is true. `operate(expression)` evaluates arithmetic strings via a shunting-yard parser (`+ - * / %`, parentheses, decimals, unary sign) and throws on any non-arithmetic character - no eval / new Function (#SCS1).
|
|
1049
|
-
300. **Pluggable object storage (`lib/storage`, #STO1)** — an **adapter** (where bytes live) crossed with a **strategy** (how keys are laid out), behind one callback-shaped contract: `put(stream, meta, cb)` -> `{key, size, contentType}` / `get(key, cb)` -> stream / `stat(key, cb)` -> meta|null / `release(key, cb)` / `resolve(key, cb)` -> `{kind:'path'|'inline'}` / `capabilities`. Slice 0 ships the `local` adapter + `sharded` strategy (`YYYY/MM/DD/<ulid><ext>`); the cas slice adds the `cas` strategy (its own block below) and the stream slice the `stream` strategy (**its own entry #302** — large sequential media + resumable uploads); `getRange(key, start, end, cb)` is present on ALL THREE local strategies with `capabilities.ranges` true — `end` is INCLUSIVE (matching the HTTP header and `createReadStream`), an over-long `end` CLAMPS, and only a `start` at/beyond the object size errors (the caller's 416); it answers from either tier, and under `cas` a released blob stays invisible to it. The serving half now ships too — `self.serveFromStorage(driverName, key, opts)` (its own entry) answers 206/416/304 with `Accept-Ranges`/`Content-Range` on both engines, gated on `capabilities.ranges`, so the flag is finally consumed by the framework itself. The `s3` adapter SHIPPED (its own entry #304): `capabilities.offload` has its first `true` and its first framework consumer (`serveFromStorage`'s presigned-307 path) in the same release, so consumers must BRANCH on `capabilities` rather than assume — every capability flag has now flipped at least once. **Keys are OPAQUE**: store what `put()` returns, never parse or compose one, which is what lets a later strategy change the layout without breaking stored references. Config is `settings.json > storage` = `{default, drivers:{<name>:{adapter, strategy, root, maxObjectSize, store}}}` — drivers NEST under their own key (the `upload.groups` shape) so a driver may legitimately be called `default`. Reached as `gina.storage()` (the `default` driver) or `gina.storage('<name>')`; the accessor is assigned UNCONDITIONALLY so an unconfigured bundle gets a named `[storage] not configured` error instead of `gina.storage is not a function`. **Metadata rides a connector seam** — `lib/storage-store.js` is the 5th member of the `job-store`/`audit-store`/`render-cache-store`/`session-store` dispatcher family (connectors.json entry name -> `.connector` -> `core/connectors/<c>/lib/storage-store.js`, throw-on-unresolvable = boot abort, and the dispatcher passes the DRIVER NAME through so two drivers may share one connectors entry); the default is an embedded SQLite file at `<root>/.meta.db`, documented **single-process-per-driver-root** (SQLite locking on a shared network FS is the known-broken case the seam exists for). **`couchbase` is the first connector store** (`core/connectors/couchbase/lib/storage-store.js`, SDK major 3|4 from the PROJECT's node_modules) and is the supported answer for a shared root — several bundles or replicas on one driver root: one JSON doc per row keyed `<prefix><driver>:<key>`, with `d`+`k` as the sargable discriminators every N1QL query filters on. Per-key atomicity is Couchbase's own CAS — every refcount verb is a `get` -> `mutateIn(...,{cas})` loop (bounded retries, then a coded `GINA_STORAGE_CAS_CONTENTION` error), and `removeIfZero` is a CAS-guarded `remove` whose mismatch IS the resurrection signal. **That atomic claim IS the multi-process sweep coordination** — concurrent sweepers are safe because exactly one claim per blob wins, so NO election layer ships (one would also make `storage:gc` report `collected:0` on a non-holder); the seam's residual is unchanged and is put-vs-sweep, never sweeper-vs-sweeper. The inline payload rides as **base64 inside the JSON doc, never a binary document body** — a binary value cannot be parsed, indexed or queried by Couchbase, which would make `listZeroRefs`/`stats` impossible, and refcount+payload must sit under ONE CAS (cost: +33%, so an `inlineThreshold` above ~14MB overflows the 20MB doc ceiling). `acquireRef` REFUSES to adopt an existing non-refcounted row (a `sharded` row or the `.driver` stamp) instead of silently stamping a count onto it. Two secondary indexes (`gina_storage_refs (d,refs,zeroAt)` / `gina_storage_keys (d,k)`) are probed via `system:indexes` and created when missing — a refusal is non-fatal but LOGS THE EXACT DDL, because an unindexed query ERRORS rather than merely running slow and the sweep's bare call would swallow it forever. ⚠️ **A statement alias that is a N1QL RESERVED WORD is refused outright by the server, so the whole verb fails to PARSE** — `stats` shipped aliasing `AS inline` and `INLINE` is reserved (measured on BOTH the 7.6 and current reserved-word references, so this was never version-specific), fixed by backticking that one alias (#B356); the other four aliases are not reserved and stay bare. **The class is INVISIBLE to this store's own suite by construction** — the tests drive a fake SDK that pattern-matches statement SHAPES and never parses N1QL, so no amount of coverage there can see a reserved word — which is why the guard is a LEXICAL pin over the captured statements carrying its own can-fail control (it must flag the pre-fix statement and clear the backticked one). Real-cluster verification against a four-node **8.0.2** deployment (the store was designed against 7.x, so this is the first 8.0 datapoint): the `system:indexes` probe and both index shapes work, the durability map matches the SDK's members, a stale-CAS `mutateIn` raises `CasMismatchError`, 32 concurrent `acquireRef` calls across 4 processes on one key yield ONE blob at refs 32, and concurrent sweepers claim exactly once over 5/5 trials with the winner varying — confirming the no-election-layer decision. ⚠️ One operational consequence of `listZeroRefs` being a GSI query: a just-zeroed blob can be invisible to the sweep briefly, so a test that zeroes and immediately sweeps may legitimately see nothing (the grace window covers it; the next pass collects). Optional `durability` (majority|majorityAndPersistToActive|persistToMajority) rides every mutation; default is the SDK's own level, the same honesty class as the embedded store's WAL `synchronous=NORMAL`. **Security, all boot-enforced or per-call**: a driver `root` inside ANY bundle's web-served tree is a boot FATAL — and the check reads `publicPath` **plus every `content.statics` target**, because a statics mapping can point anywhere on disk, so checking `publicPath` alone leaves a hole; the metadata DB living inside the root is safe only BECAUSE of that fatal. Per-call key handling runs three guards **in a load-bearing ORDER**: confinement FIRST (a lib-local `confineToBase` copy — the engine copies are closure-private), THEN canonical-form, THEN reserved dot-segments. Reversing the first two silently shadows the security boundary: `..` starts with a dot, so a reserved-check-first ordering answers every traversal attempt with "reserved", `confineToBase` is never reached, and a traversal test passes for the wrong reason — measured and fixed during the build. Non-canonical keys (`a/../b`) are refused because they would address one file under two metadata keys; `.tmp` / `.meta.db` are refused because they are reachable without leaving the root. **Write path** = the #B223 discipline generalised: stream to `<root>/.tmp/` then publish by `rename(2)` (atomic — a reader never sees a partial; same root ⇒ same FS ⇒ no EXDEV), error listeners armed AT STREAM CREATION on BOTH streams (#B143 — `.pipe()` returns the destination, so one chained listener covers only one, and an unlistened `'error'` takes the bundle down), a settled latch (`close` also follows `error`), the REAL error propagated (never a fabricated one), size measured from the PUBLISHED file (not an in-flight counter), and a metadata-write failure rolls the object back so a caller that got no key is never left with unreferenceable bytes. Durability is BY STRATEGY since the cas slice: `sharded` writes are NOT fsynced (disclosed in the module JSDoc the way `lib/audit` discloses its own tail), while `cas` publishes fsync by DEFAULT — the temp file HARD before the rename (a failure fails the put), the parent directory BEST-EFFORT after it (platforms that cannot fsync a directory — Windows, some network mounts — skip that half silently; macOS honours fsync as the platform defines it, F_FULLFSYNC being native-addon territory gina does not ship). The first fsync anywhere in gina — dir-fd fsync measured working on darwin under both runtimes before shipping. **Size tiering (the tiering slice)**: objects strictly UNDER a driver's `inlineThreshold` (unit-suffixed string, default `"64KB"`, `"0B"` = off per driver) are stored INLINE — one metadata-store transaction carrying the payload as a binary-safe `data` Buffer on the row, no temp file, no directories, no filesystem at all — while at-or-above objects take the file path; the crossing is decided mid-stream by buffering the head (memory bound: threshold + one chunk per in-flight put) and spilling byte-order-exact into the temp+rename path the moment the running total reaches the threshold. The key shape is IDENTICAL in both tiers (keys stay opaque); `resolve()` answers `{kind:'inline'}` (bytes via `get()`, which serves the row as a stream), `stat()` strips the payload, `release()` needs no special casing, and `capabilities.inline` is true iff the threshold is active. Reads are payload-presence-driven, NOT threshold-driven, so changing the threshold is retroactively safe — existing objects stay readable on either side, and the embedded store migrates a pre-tiering database in place (additive `data BLOB` column, idempotent). 64KB is the MEASURED knee, not folklore (2026-08-13 bench on the shipped stack, sequential puts: inline 10.8-12.9x faster at 1KB, 8-8.5x at 4KB, 5.7-6.6x at 16KB, 2.7-3.3x at 64KB, destabilizing above — on APFS/NVMe, the case MOST favourable to the file path, so slower/network filesystems widen the win). Inline durability is the same class as the un-fsynced rename path (WAL + synchronous=NORMAL: a crash can lose the last committed transaction, never a torn row). Two operational facts stated rather than implied: the metadata store becomes PAYLOAD-BEARING (losing it loses sub-threshold objects, not just their metadata — file-backed bytes survive an index loss; any root-level backup still carries `<root>/.meta.db`), and sub-threshold objects are not individually visible on disk (the `sharded` SSH-browsability rationale holds only at-or-above the threshold). A connector-backed store inherits payload rows, so binary-safe round-trip is part of the SEAM contract, not implementation detail. Direct factory callers get tiering OFF when the key is absent — defaults resolve in `start()`, the `maxObjectSize` pattern, which is what keeps the adapter's low-level tests pinned to the file path. **The `cas` strategy (the cas slice): the key IS the content address** — `blobs/<algo>/<aa>/<bb>/<hex>`, extension-less BY CONSTRUCTION (an extension would derive from the untrusted `originalName`, so identical bytes under different names would mint different keys and silently break dedup; `contentType` lives in the metadata row) — so two puts of identical bytes return the SAME key with refs=2 and `deduplicated:true` on the second result, metadata first-write-wins. `release()` DECREMENTS and never deletes bytes; a blob reaching 0 refs is stamped and reads as ABSENT through get/stat/resolve/findByDigest (the grace window is GC detail, not a caller-visible afterlife), while a re-upload inside the window resurrects it for free. The GC sweep (per-driver unref'd setInterval — the lib/job precedent, `lib/cron` stays dormant; `sweepInterval` default '15m', '0s' = off; `sweepGrace` default '1h'; both REASONED defaults, not benchmarks — they gate reclaim latency only) collects blobs at 0 past grace in a load-bearing ORDER: claim the row FIRST (`removeIfZero` — a guarded delete a concurrent `acquireRef` resurrection always beats), unlink the file SECOND. The inverse order can LOSE BYTES under a racing dedup-hit put (it would discard its temp against a row whose file just vanished); this order's crash window merely orphans an UNREFERENCED file — invisible to every verb, harmless, `storage:verify` territory (the CLI/maintenance slice below) — and a dedup hit whose blob file is missing (exactly that residue) HEALS by renaming its own temp in. In-process the sweep-vs-put race cannot interleave AT ALL (the embedded store is synchronous, so acquire→check→publish and claim→unlink each run in one uninterruptible JS turn); the multi-process residual belongs to future connector stores and is documented on the seam contract. `findByDigest(algo, hex, cb)` -> key|null is the ONLY sanctioned digest→key door (keys stay opaque even though cas keys LOOK parseable) and is a DEDUP ORACLE — "someone already uploaded this exact file" is an information leak across users — so it ships as a driver verb ONLY, no HTTP endpoint: a consumer exposing one owns auth and per-driver scoping. Algorithm agility: `hash` (default 'sha256') is per-driver config validated at boot against THIS runtime's `crypto.getHashes()` — an unavailable name is FATAL, and the capability is runtime-scoped (measured: sha256/sha512 on both supported runtimes, blake2b512 Node-only) — and since the algo is a path segment, a hash CHANGE is additive (the old namespace stays addressable and findByDigest-able under its own algo, dedup does not span namespaces, the boot notes the change once and restamps) while a STRATEGY change on populated storage is a re-key migration, which is why `start()` now stamps every driver root (a reserved `.driver` row whose stamp rides the store's binary-safe `data` slot) and warns EVERY boot on mismatch without restamping — surfaced through the new `opt.warn` hook (a callback, not a logger import; `start()`'s boolean return is unchanged). The refcount machinery is four OPTIONAL seam verbs (`acquireRef`/`releaseRef`/`listZeroRefs`/`removeIfZero`) the embedded store implements atomically-by-synchrony from plain UPDATE/SELECT/INSERT — deliberately NO `RETURNING`, which sits outside the Bun sqlite adapter's measured surface — plus additive idempotent `refs`/`zero_at` migrations; a cas driver REFUSES to build over a store lacking the verbs (boot fatal), and `refs` appears on get()/stat() output only for refcounted rows (the `data` pattern, so non-cas rows keep their exact shape — `set()` must never touch a refcounted row, REPLACE would reset the count). Config keys are PER-STRATEGY (`STRATEGY_KEYS`: `hash`/`fsync`/`sweepInterval`/`sweepGrace` are consumed by cas and ignored-with-a-warning under sharded; durations parse via `parseDuration`, unit-required exactly like the size keys — '15m' means minutes here and megabytes there, which is why neither parser guesses). The sharded write path, key shape and durability are byte-for-byte unchanged, and size tiering applies under cas exactly as under sharded — an inline cas blob is a refcounted row carrying `data`, dedup included, collected by row-deletion alone. `maxObjectSize` requires an EXPLICIT unit (`"50MB"`); a bare number warns and falls back to the default, deliberately stricter than `settings.upload` where a bare number means MB for back-compat — `upload` already reads a bare number as a count (`maxFields`) in one key and as MB (`maxFieldsSize`) in another, so there is no house meaning to inherit and guessing would be the bug. **Framework independence is the structural contract**: `lib/storage` imports no gina core, no `lib` registry, and no injected globals (`getContext`/`getConfig`/`_()`), so the web-served roots are INJECTED into `validateConfig` rather than read; `lib/sqlite-driver` is the ONE sanctioned import and an exact-count test pins it so a second exception cannot arrive as drift. `validateConfig(block, {servedRoots})` is pure and enable-agnostic (the `lib/render-cache` shape): it lints unconditionally and returns `{fatal, warnings, driverCount}` — the CALLER owns aborting, and the three semantics often misattributed to the validator (lint-always / build-only-when-enabled / fatal-downgrades-to-warn) all live in the boot block, not in it. Boot wiring sits in `gna.js` inside `server.on('started')`, i.e. **POST-listen**, so a storage fatal exits on an already-bound port exactly as the #RC4 and #AI6 blocks do; a pre-listen refusal would mean moving the lint into `core/server.js` `init()` where the audit trail does it. **Slice 1 binds the upload path (the #B224 resolution vehicle): a settings.json upload.groups.<name>.driver key routes that group's self.store() step through the named driver.** store() partitions per file on the RESOLVED group (req.files[].group — which since #B140, same arc, carries untagged instead of undefined for untagged parts; caller-synthesized lists normalize the same way): files in driver-less groups keep the historical move path byte-for-byte (same entry shapes — no new fields, same false success sentinel, same abort-on-first-error), while routed files stream from their parse-time staging path into driver.put() sequentially (abort on first failure, NO rollback of already-published objects, ENOENT-tolerant post-publish source unlink — the mover's exact semantics) and their result entries carry {file, group, driver, key, size, type, encoding} with NO filename: key is the opaque reference, size is the layer's own measurement of the published bytes, reads go through gina.storage(driver). target may be null ONLY when every file routes (a move-path file with no target errors naming the file + group; the target dir is only created when a move-path file exists). Boot lint: gna.js collects every bundle's group→driver bindings as NEUTRAL {owner, driver, path} tuples into validateConfig's context.groupBindings — a binding naming an undefined driver (or ANY binding when no storage/drivers block exists) is FATAL; path BESIDE driver is LEGAL (for a routed group path is only the parse-time staging dir — the multipart landing behavior is unchanged) but a path INSIDE the driver's root warns (staging into the store tree strands files no key references). Parse-time enforcement (#B50 gate, allowedExtensions, isMultipleAllowed, maxFields) is untouched; a routed file additionally meets the driver's maxObjectSize at put() time. Riding the same arc, #B224 resolved as retire+declare: the scaffold template's inert advertised keys (filePrefix/subFolder/per-group maxFieldsSize — and the inert block-level encoding, which busboy ignores in favor of its hardcoded utf8 param decoder) are retired, the three false per-group-redefinability comments corrected, and schema/settings.json now declares the REAL upload key set at both levels (additionalProperties stays true at BOTH — apps legitimately carry their own group keys, e.g. app-side composition). **The CLI/maintenance slice: `storage:stats` / `storage:gc` / `storage:verify` + the always-on admin-gated `/_gina/storage/{stats,gc,verify}` endpoint family.** The spine: who touches the store depends on who OWNS it (the embedded store is single-process-per-root), so each command sends its own request to the bundle's assigned ports (the cache:stats candidates walk — advance on ECONNREFUSED): a RESPONSE means RUNNING and the owning process did the work via the endpoint (engine-agnostic `server.js onRequest()` ONLY — the isaac listener falls through via its cb, the `/_gina/agent` precedent; `app.json admin.allowFrom` gate, ^-anchored patterns per the release-watch lesson, NO dev gate; stats/verify GET, gc POST — a gc pass is a mutation); every-port-ECONNREFUSED means DOWN and the CLI resolves the bundle's raw `settings.storage` (requireJSON — scaffolded configs carry comments) and builds the REAL driver through the boot's own exported seams (`_resolveDriverConf`/`_FACTORIES`/`_createEmbeddedMetaStore`, `sweepInterval:0`; a missing root is reported, never mkdir'd — a stats read must not mutate); a TIMEOUT or any other socket error opens NOTHING (the bundle may be alive and owning the store). Connector-backed stores have no offline path — named-and-skipped ("nothing local to open; start the bundle and re-run"). New module/driver surface: `lib.storage.list()` (built driver names, `[]` before start — the enumeration door, so callers need no isStarted() pre-check); `stats(cb)` on BOTH strategies → `{name, strategy, root, capabilities, store}` with `store` = the metadata store's OPTIONAL `stats()` aggregate `{objects, refcounted, zeroRefPending, inline, bytes}` (reserved dot-key rows excluded — the `.driver` stamp must not read as an object) or `null` = "store reports no stats"; cas `sweepNow([{dryRun}], cb)` — `_sweepOnce` PROMOTED to a documented door, the underscore alias kept for the test seam — → `{collected|collectable, drained}` where `drained` answers "anything older-than-grace left?" so gc DRAINS by looping the batch-capped pass (hard-bounded at 1000 passes) instead of guessing the cap, and dryRun lists without claiming a row; cas `verify([{fix}], cb)` — the files↔rows scan, BOTH directions age-gated past `sweepGrace` (a young file may be in-flight work; a young row sits inside put()'s acquire→rename window, where a row legitimately precedes its file): `file-without-row` (the sweep's crash residue — FIXABLE, `fix` unlinks; OFFLINE-ONLY: the CLI refuses `--fix` while the bundle runs and the endpoint never parses a fix flag, making the refusal structural) vs `row-without-file` at refs>=1 (LOSS EVIDENCE — reported, NEVER auto-fixed: deleting the row would destroy the signal; zero-ref rows are skipped — the sweep's claim + ENOENT-tolerant unlink self-heals them, so reporting would flap); the findings LIST is capped at 1000 with exact counts + a truncation flag (a silent cap would read as "that was all of them"); the rows direction rides the second OPTIONAL seam verb `listKeys(afterKey, limit, cb)` (key-ordered cursor pages, dot-keys excluded) — a store lacking it degrades verify to files-only (`rowsChecked:false`). `sharded` has no sweep and no verify in v1 — named-and-skipped, never an error; gc/verify against a root shared by several bundles are idempotent (same result from whichever bundle). CLI grammar is the cache family's: `<bundle> @<project>` or all-bundles, `--driver=`, `--format=json` (fs.writeSync flush-before-exit), `--dry-run` (gc), `--fix` (verify); `storage:` joined bin/cli's `allowedOffline` (the hard gate — a topic dir alone is unreachable, the dispatch itself is pure `lib/cmd/<topic>/<action>.js` convention). ⚠️ **The metadata store NEVER invokes a caller's callback from inside its own `try` (#B565).** Seven methods (`set`, `remove`, `acquireRef`, `releaseRef`, `listZeroRefs`, `removeIfZero`, `listKeys`) used to, and the consequence was twofold: the store's `catch` SWALLOWED an error the application threw, then re-invoked the SAME callback with that error dressed as a store error. Any caller that latches on first settle — `local-cas` `verify()` does — read the re-entry as a no-op and never completed, so the operation HUNG rather than reporting; the caller saw neither a result nor an exception. `get()` always had the correct shape and is the template: the `try` wraps ONLY the SQLite statement, the callback runs after it on every path, and a genuine store error still arrives as `fn(err)`. Pinned by `test/lib/storage-meta-store-callback-isolation.test.js` (17 arms: every method must let an application throw escape AND must invoke the callback exactly once). Same family as the #B473/#B475/#B429 settle-discipline rule — a completion handle settles on a per-call channel, and a library must never convert a caller's exception into its own error.
|
|
1049
|
+
300. **Pluggable object storage (`lib/storage`, #STO1)** — an **adapter** (where bytes live) crossed with a **strategy** (how keys are laid out), behind one callback-shaped contract: `put(stream, meta, cb)` -> `{key, size, contentType}` / `get(key, cb)` -> stream / `stat(key, cb)` -> meta|null / `release(key, cb)` / `resolve(key, cb)` -> `{kind:'path'|'inline'}` / `capabilities`. Slice 0 ships the `local` adapter + `sharded` strategy (`YYYY/MM/DD/<ulid><ext>`); the cas slice adds the `cas` strategy (its own block below) and the stream slice the `stream` strategy (**its own entry #302** — large sequential media + resumable uploads); `getRange(key, start, end, cb)` is present on ALL THREE local strategies with `capabilities.ranges` true — `end` is INCLUSIVE (matching the HTTP header and `createReadStream`), an over-long `end` CLAMPS, and only a `start` at/beyond the object size errors (the caller's 416); it answers from either tier, and under `cas` a released blob stays invisible to it. The serving half now ships too — `self.serveFromStorage(driverName, key, opts)` (its own entry) answers 206/416/304 with `Accept-Ranges`/`Content-Range` on both engines, gated on `capabilities.ranges`, so the flag is finally consumed by the framework itself. The `s3` adapter SHIPPED (its own entry #304): `capabilities.offload` has its first `true` and its first framework consumer (`serveFromStorage`'s presigned-307 path) in the same release, so consumers must BRANCH on `capabilities` rather than assume — every capability flag has now flipped at least once. **Keys are OPAQUE**: store what `put()` returns, never parse or compose one, which is what lets a later strategy change the layout without breaking stored references. Config is `settings.json > storage` = `{default, drivers:{<name>:{adapter, strategy, root, maxObjectSize, store}}}` — drivers NEST under their own key (the `upload.groups` shape) so a driver may legitimately be called `default`. Reached as `gina.storage()` (the `default` driver) or `gina.storage('<name>')`; the accessor is assigned UNCONDITIONALLY so an unconfigured bundle gets a named `[storage] not configured` error instead of `gina.storage is not a function`. **Metadata rides a connector seam** — `lib/storage-store.js` is the 5th member of the `job-store`/`audit-store`/`render-cache-store`/`session-store` dispatcher family (connectors.json entry name -> `.connector` -> `core/connectors/<c>/lib/storage-store.js`, throw-on-unresolvable = boot abort, and the dispatcher passes the DRIVER NAME through so two drivers may share one connectors entry); the default is an embedded SQLite file at `<root>/.meta.db`, documented **single-process-per-driver-root** (SQLite locking on a shared network FS is the known-broken case the seam exists for). **`couchbase` is the first connector store** (`core/connectors/couchbase/lib/storage-store.js`, SDK major 3|4 from the PROJECT's node_modules) and is the supported answer for a shared root — several bundles or replicas on one driver root: one JSON doc per row keyed `<prefix><driver>:<key>`, with `d`+`k` as the sargable discriminators every N1QL query filters on. Per-key atomicity is Couchbase's own CAS — every refcount verb is a `get` -> `mutateIn(...,{cas})` loop (bounded retries, then a coded `GINA_STORAGE_CAS_CONTENTION` error), and `removeIfZero` is a CAS-guarded `remove` whose mismatch IS the resurrection signal. **That atomic claim IS the multi-process sweep coordination** — concurrent sweepers are safe because exactly one claim per blob wins, so NO election layer ships (one would also make `storage:gc` report `collected:0` on a non-holder); the seam's residual is unchanged and is put-vs-sweep, never sweeper-vs-sweeper. The inline payload rides as **base64 inside the JSON doc, never a binary document body** — a binary value cannot be parsed, indexed or queried by Couchbase, which would make `listZeroRefs`/`stats` impossible, and refcount+payload must sit under ONE CAS (cost: +33%, so an `inlineThreshold` above ~14MB overflows the 20MB doc ceiling). `acquireRef` REFUSES to adopt an existing non-refcounted row (a `sharded` row or the `.driver` stamp) instead of silently stamping a count onto it. Two secondary indexes (`gina_storage_refs (d,refs,zeroAt)` / `gina_storage_keys (d,k)`) are probed via `system:indexes` and created when missing — a refusal is non-fatal but LOGS THE EXACT DDL, because an unindexed query ERRORS rather than merely running slow and the sweep's bare call would swallow it forever. ⚠️ **A statement alias that is a N1QL RESERVED WORD is refused outright by the server, so the whole verb fails to PARSE** — `stats` shipped aliasing `AS inline` and `INLINE` is reserved (measured on BOTH the 7.6 and current reserved-word references, so this was never version-specific), fixed by backticking that one alias (#B356); the other four aliases are not reserved and stay bare. **The class is INVISIBLE to this store's own suite by construction** — the tests drive a fake SDK that pattern-matches statement SHAPES and never parses N1QL, so no amount of coverage there can see a reserved word — which is why the guard is a LEXICAL pin over the captured statements carrying its own can-fail control (it must flag the pre-fix statement and clear the backticked one). Real-cluster verification against a four-node **8.0.2** deployment (the store was designed against 7.x, so this is the first 8.0 datapoint): the `system:indexes` probe and both index shapes work, the durability map matches the SDK's members, a stale-CAS `mutateIn` raises `CasMismatchError`, 32 concurrent `acquireRef` calls across 4 processes on one key yield ONE blob at refs 32, and concurrent sweepers claim exactly once over 5/5 trials with the winner varying — confirming the no-election-layer decision. ⚠️ One operational consequence of `listZeroRefs` being a GSI query: a just-zeroed blob can be invisible to the sweep briefly, so a test that zeroes and immediately sweeps may legitimately see nothing (the grace window covers it; the next pass collects). Optional `durability` (majority|majorityAndPersistToActive|persistToMajority) rides every mutation; default is the SDK's own level, the same honesty class as the embedded store's WAL `synchronous=NORMAL`. **Security, all boot-enforced or per-call**: a driver `root` inside ANY bundle's web-served tree is a boot FATAL — and the check reads `publicPath` **plus every `content.statics` target**, because a statics mapping can point anywhere on disk, so checking `publicPath` alone leaves a hole; the metadata DB living inside the root is safe only BECAUSE of that fatal. Per-call key handling runs three guards **in a load-bearing ORDER**: confinement FIRST (a lib-local `confineToBase` copy — the engine copies are closure-private), THEN canonical-form, THEN reserved dot-segments. Reversing the first two silently shadows the security boundary: `..` starts with a dot, so a reserved-check-first ordering answers every traversal attempt with "reserved", `confineToBase` is never reached, and a traversal test passes for the wrong reason — measured and fixed during the build. Non-canonical keys (`a/../b`) are refused because they would address one file under two metadata keys; `.tmp` / `.meta.db` are refused because they are reachable without leaving the root. **Write path** = the #B223 discipline generalised: stream to `<root>/.tmp/` then publish by `rename(2)` (atomic — a reader never sees a partial; same root ⇒ same FS ⇒ no EXDEV), error listeners armed AT STREAM CREATION on BOTH streams (#B143 — `.pipe()` returns the destination, so one chained listener covers only one, and an unlistened `'error'` takes the bundle down), a settled latch (`close` also follows `error`), the REAL error propagated (never a fabricated one), size measured from the PUBLISHED file (not an in-flight counter), and a metadata-write failure rolls the object back so a caller that got no key is never left with unreferenceable bytes. Durability is BY STRATEGY since the cas slice: `sharded` writes are NOT fsynced (disclosed in the module JSDoc the way `lib/audit` discloses its own tail), while `cas` publishes fsync by DEFAULT — the temp file HARD before the rename (a failure fails the put), the parent directory BEST-EFFORT after it (platforms that cannot fsync a directory — Windows, some network mounts — skip that half silently; macOS honours fsync as the platform defines it, F_FULLFSYNC being native-addon territory gina does not ship). The first fsync anywhere in gina — dir-fd fsync measured working on darwin under both runtimes before shipping. **Size tiering (the tiering slice)**: objects strictly UNDER a driver's `inlineThreshold` (unit-suffixed string, default `"64KB"`, `"0B"` = off per driver) are stored INLINE — one metadata-store transaction carrying the payload as a binary-safe `data` Buffer on the row, no temp file, no directories, no filesystem at all — while at-or-above objects take the file path; the crossing is decided mid-stream by buffering the head (memory bound: threshold + one chunk per in-flight put) and spilling byte-order-exact into the temp+rename path the moment the running total reaches the threshold. The key shape is IDENTICAL in both tiers (keys stay opaque); `resolve()` answers `{kind:'inline'}` (bytes via `get()`, which serves the row as a stream), `stat()` strips the payload, `release()` needs no special casing, and `capabilities.inline` is true iff the threshold is active. Reads are payload-presence-driven, NOT threshold-driven, so changing the threshold is retroactively safe — existing objects stay readable on either side, and the embedded store migrates a pre-tiering database in place (additive `data BLOB` column, idempotent). 64KB is the MEASURED knee, not folklore (2026-08-13 bench on the shipped stack, sequential puts: inline 10.8-12.9x faster at 1KB, 8-8.5x at 4KB, 5.7-6.6x at 16KB, 2.7-3.3x at 64KB, destabilizing above — on APFS/NVMe, the case MOST favourable to the file path, so slower/network filesystems widen the win). Inline durability is the same class as the un-fsynced rename path (WAL + synchronous=NORMAL: a crash can lose the last committed transaction, never a torn row). Two operational facts stated rather than implied: the metadata store becomes PAYLOAD-BEARING (losing it loses sub-threshold objects, not just their metadata — file-backed bytes survive an index loss; any root-level backup still carries `<root>/.meta.db`), and sub-threshold objects are not individually visible on disk (the `sharded` SSH-browsability rationale holds only at-or-above the threshold). A connector-backed store inherits payload rows, so binary-safe round-trip is part of the SEAM contract, not implementation detail. Direct factory callers get tiering OFF when the key is absent — defaults resolve in `start()`, the `maxObjectSize` pattern, which is what keeps the adapter's low-level tests pinned to the file path. **The `cas` strategy (the cas slice): the key IS the content address** — `blobs/<algo>/<aa>/<bb>/<hex>`, extension-less BY CONSTRUCTION (an extension would derive from the untrusted `originalName`, so identical bytes under different names would mint different keys and silently break dedup; `contentType` lives in the metadata row) — so two puts of identical bytes return the SAME key with refs=2 and `deduplicated:true` on the second result, metadata first-write-wins. `release()` DECREMENTS and never deletes bytes; a blob reaching 0 refs is stamped and reads as ABSENT through get/stat/resolve/findByDigest (the grace window is GC detail, not a caller-visible afterlife), while a re-upload inside the window resurrects it for free. The GC sweep (per-driver unref'd setInterval — the lib/job precedent, `lib/cron` stays dormant; `sweepInterval` default '15m', '0s' = off; `sweepGrace` default '1h'; both REASONED defaults, not benchmarks — they gate reclaim latency only) collects blobs at 0 past grace in a load-bearing ORDER: claim the row FIRST (`removeIfZero` — a guarded delete a concurrent `acquireRef` resurrection always beats), unlink the file SECOND. The inverse order can LOSE BYTES under a racing dedup-hit put (it would discard its temp against a row whose file just vanished); this order's crash window merely orphans an UNREFERENCED file — invisible to every verb, harmless, `storage:verify` territory (the CLI/maintenance slice below) — and a dedup hit whose blob file is missing (exactly that residue) HEALS by renaming its own temp in. In-process the sweep-vs-put race cannot interleave AT ALL (the embedded store is synchronous, so acquire→check→publish and claim→unlink each run in one uninterruptible JS turn); the multi-process residual belongs to future connector stores and is documented on the seam contract. `findByDigest(algo, hex, cb)` -> key|null is the ONLY sanctioned digest→key door (keys stay opaque even though cas keys LOOK parseable) and is a DEDUP ORACLE — "someone already uploaded this exact file" is an information leak across users — so it ships as a driver verb ONLY, no HTTP endpoint: a consumer exposing one owns auth and per-driver scoping. Algorithm agility: `hash` (default 'sha256') is per-driver config validated at boot against THIS runtime's `crypto.getHashes()` — an unavailable name is FATAL, and the capability is runtime-scoped (measured: sha256/sha512 on both supported runtimes, blake2b512 Node-only) — and since the algo is a path segment, a hash CHANGE is additive (the old namespace stays addressable and findByDigest-able under its own algo, dedup does not span namespaces, the boot notes the change once and restamps) while a STRATEGY change on populated storage is a re-key migration, which is why `start()` now stamps every driver root (a reserved `.driver` row whose stamp rides the store's binary-safe `data` slot) and warns EVERY boot on mismatch without restamping — surfaced through the new `opt.warn` hook (a callback, not a logger import; `start()`'s boolean return is unchanged). The refcount machinery is four OPTIONAL seam verbs (`acquireRef`/`releaseRef`/`listZeroRefs`/`removeIfZero`) the embedded store implements atomically-by-synchrony from plain UPDATE/SELECT/INSERT — deliberately NO `RETURNING`, which sits outside the Bun sqlite adapter's measured surface — plus additive idempotent `refs`/`zero_at` migrations; a cas driver REFUSES to build over a store lacking the verbs (boot fatal), and `refs` appears on get()/stat() output only for refcounted rows (the `data` pattern, so non-cas rows keep their exact shape — `set()` must never touch a refcounted row, REPLACE would reset the count). Config keys are PER-STRATEGY (`STRATEGY_KEYS`: `hash`/`fsync`/`sweepInterval`/`sweepGrace` are consumed by cas and ignored-with-a-warning under sharded; durations parse via `parseDuration`, unit-required exactly like the size keys — '15m' means minutes here and megabytes there, which is why neither parser guesses). The sharded write path, key shape and durability are byte-for-byte unchanged, and size tiering applies under cas exactly as under sharded — an inline cas blob is a refcounted row carrying `data`, dedup included, collected by row-deletion alone. `maxObjectSize` requires an EXPLICIT unit (`"50MB"`); a bare number warns and falls back to the default, deliberately stricter than `settings.upload` where a bare number means MB for back-compat — `upload` already reads a bare number as a count (`maxFields`) in one key and as MB (`maxFieldsSize`) in another, so there is no house meaning to inherit and guessing would be the bug. **Framework independence is the structural contract**: `lib/storage` imports no gina core, no `lib` registry, and no injected globals (`getContext`/`getConfig`/`_()`), so the web-served roots are INJECTED into `validateConfig` rather than read; `lib/sqlite-driver` is the ONE sanctioned import and an exact-count test pins it so a second exception cannot arrive as drift. `validateConfig(block, {servedRoots})` is pure and enable-agnostic (the `lib/render-cache` shape): it lints unconditionally and returns `{fatal, warnings, driverCount}` — the CALLER owns aborting, and the three semantics often misattributed to the validator (lint-always / build-only-when-enabled / fatal-downgrades-to-warn) all live in the boot block, not in it. Boot wiring sits in `gna.js` inside `server.on('started')`, i.e. **POST-listen**, so a storage fatal exits on an already-bound port exactly as the #RC4 and #AI6 blocks do; a pre-listen refusal would mean moving the lint into `core/server.js` `init()` where the audit trail does it. **Slice 1 binds the upload path (the #B224 resolution vehicle): a settings.json upload.groups.<name>.driver key routes that group's self.store() step through the named driver.** store() partitions per file on the RESOLVED group (req.files[].group — which since #B140, same arc, carries untagged instead of undefined for untagged parts; caller-synthesized lists normalize the same way): files in driver-less groups keep the historical move path byte-for-byte (same entry shapes — no new fields, same false success sentinel, same abort-on-first-error), while routed files stream from their parse-time staging path into driver.put() sequentially (abort on first failure, NO rollback of already-published objects, ENOENT-tolerant post-publish source unlink — the mover's exact semantics) and their result entries carry {file, group, driver, key, size, type, encoding} with NO filename: key is the opaque reference, size is the layer's own measurement of the published bytes, reads go through gina.storage(driver). target may be null ONLY when every file routes (a move-path file with no target errors naming the file + group; the target dir is only created when a move-path file exists). Boot lint: gna.js collects every bundle's group→driver bindings as NEUTRAL {owner, driver, path} tuples into validateConfig's context.groupBindings — a binding naming an undefined driver (or ANY binding when no storage/drivers block exists) is FATAL; path BESIDE driver is LEGAL (for a routed group path is only the parse-time staging dir — the multipart landing behavior is unchanged) but a path INSIDE the driver's root warns (staging into the store tree strands files no key references). Parse-time enforcement (#B50 gate, allowedExtensions, isMultipleAllowed, maxFields) is untouched; a routed file additionally meets the driver's maxObjectSize at put() time. Riding the same arc, #B224 resolved as retire+declare: the scaffold template's inert advertised keys (filePrefix/subFolder/per-group maxFieldsSize — and the inert block-level encoding, which busboy ignores in favor of its hardcoded utf8 param decoder) are retired, the three false per-group-redefinability comments corrected, and schema/settings.json now declares the REAL upload key set at both levels (additionalProperties stays true at BOTH — apps legitimately carry their own group keys, e.g. app-side composition). **The CLI/maintenance slice: `storage:stats` / `storage:gc` / `storage:verify` + the always-on admin-gated `/_gina/storage/{stats,gc,verify}` endpoint family.** The spine: who touches the store depends on who OWNS it (the embedded store is single-process-per-root), so each command sends its own request to the bundle's assigned ports (the cache:stats candidates walk — advance on ECONNREFUSED): a RESPONSE means RUNNING and the owning process did the work via the endpoint (engine-agnostic `server.js onRequest()` ONLY — the isaac listener falls through via its cb, the `/_gina/agent` precedent; `app.json admin.allowFrom` gate, ^-anchored patterns per the release-watch lesson, NO dev gate; stats/verify GET, gc POST — a gc pass is a mutation; the handlers read `?driver=`/`?dryRun=` from `request.originalUrl`, because isaac strips the query from `request.url` before its cb hands the request to `server.js` — until 0.7.1 a RUNNING isaac bundle ignored both, so `storage:gc --dry-run` ran a REAL collection and `--driver=` scoped nothing (#B710); the URL tests stay on `request.url`); every-port-ECONNREFUSED means DOWN and the CLI resolves the bundle's raw `settings.storage` (requireJSON — scaffolded configs carry comments) and builds the REAL driver through the boot's own exported seams (`_resolveDriverConf`/`_FACTORIES`/`_createEmbeddedMetaStore`, `sweepInterval:0`; a missing root is reported, never mkdir'd — a stats read must not mutate); a TIMEOUT or any other socket error opens NOTHING (the bundle may be alive and owning the store). Connector-backed stores have no offline path — named-and-skipped ("nothing local to open; start the bundle and re-run"). New module/driver surface: `lib.storage.list()` (built driver names, `[]` before start — the enumeration door, so callers need no isStarted() pre-check); `stats(cb)` on BOTH strategies → `{name, strategy, root, capabilities, store}` with `store` = the metadata store's OPTIONAL `stats()` aggregate `{objects, refcounted, zeroRefPending, inline, bytes}` (reserved dot-key rows excluded — the `.driver` stamp must not read as an object) or `null` = "store reports no stats"; cas `sweepNow([{dryRun}], cb)` — `_sweepOnce` PROMOTED to a documented door, the underscore alias kept for the test seam — → `{collected|collectable, drained}` where `drained` answers "anything older-than-grace left?" so gc DRAINS by looping the batch-capped pass (hard-bounded at 1000 passes) instead of guessing the cap, and dryRun lists without claiming a row; cas `verify([{fix}], cb)` — the files↔rows scan, BOTH directions age-gated past `sweepGrace` (a young file may be in-flight work; a young row sits inside put()'s acquire→rename window, where a row legitimately precedes its file): `file-without-row` (the sweep's crash residue — FIXABLE, `fix` unlinks; OFFLINE-ONLY: the CLI refuses `--fix` while the bundle runs and the endpoint never parses a fix flag, making the refusal structural) vs `row-without-file` at refs>=1 (LOSS EVIDENCE — reported, NEVER auto-fixed: deleting the row would destroy the signal; zero-ref rows are skipped — the sweep's claim + ENOENT-tolerant unlink self-heals them, so reporting would flap); the findings LIST is capped at 1000 with exact counts + a truncation flag (a silent cap would read as "that was all of them"); the rows direction rides the second OPTIONAL seam verb `listKeys(afterKey, limit, cb)` (key-ordered cursor pages, dot-keys excluded) — a store lacking it degrades verify to files-only (`rowsChecked:false`). `sharded` has no sweep and no verify in v1 — named-and-skipped, never an error; gc/verify against a root shared by several bundles are idempotent (same result from whichever bundle). CLI grammar is the cache family's: `<bundle> @<project>` or all-bundles, `--driver=`, `--format=json` (fs.writeSync flush-before-exit), `--dry-run` (gc), `--fix` (verify); `storage:` joined bin/cli's `allowedOffline` (the hard gate — a topic dir alone is unreachable, the dispatch itself is pure `lib/cmd/<topic>/<action>.js` convention). ⚠️ **The metadata store NEVER invokes a caller's callback from inside its own `try` (#B565).** Seven methods (`set`, `remove`, `acquireRef`, `releaseRef`, `listZeroRefs`, `removeIfZero`, `listKeys`) used to, and the consequence was twofold: the store's `catch` SWALLOWED an error the application threw, then re-invoked the SAME callback with that error dressed as a store error. Any caller that latches on first settle — `local-cas` `verify()` does — read the re-entry as a no-op and never completed, so the operation HUNG rather than reporting; the caller saw neither a result nor an exception. `get()` always had the correct shape and is the template: the `try` wraps ONLY the SQLite statement, the callback runs after it on every path, and a genuine store error still arrives as `fn(err)`. Pinned by `test/lib/storage-meta-store-callback-isolation.test.js` (17 arms: every method must let an application throw escape AND must invoke the callback exactly once). Same family as the #B473/#B475/#B429 settle-discipline rule — a completion handle settles on a per-call channel, and a library must never convert a caller's exception into its own error.
|
|
1050
1050
|
301. **FormValidator — submit pipeline, async `query` rule, loading state & error rendering (consolidates former #257/#261/#264/#296/#297; the engine/binding/a11y parent is #209).** **Rules & binding (former #257 — #B127/#B128/#B129 0.5.20, #B138 0.5.22):** numbered `is` aliases (`is0`, `is1`, … — multiple `is` conditions per field) INSTALL and enforce on client AND server (anchored `/^is\d+$/`) with a DISTINCT error key per alias on both sides (previously the per-rule typecheck `continue`d on exactly the undefined state the lazy installer required — silently skipping every `is<N>` key — and 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. The `is` grammar remains ONE binary comparison (`=== !== == != < > <= >=`), no `&&` compounds. #B138: the shared `_currentValidatorAlias` handshake slot re-arms immediately before EVERY delegation (it was armed once at install while `is()` consumes-with-delete — any re-application against the same instance collapsed every numbered rule onto the bare key `is`, last-declared-wins, its message rendered twice). Injected forms (`validateFormById`) resolve their declared rule ATTRIBUTE-FIRST (`data-gina-form-rule`; the id-derived name stays the fallback) — pre-fix a form injected after boot bound `{}` rules. 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). The cross-field `_case_` scan coerces the CASE VALUE (declare boolean cases as JSON booleans; the strings "true"/"false" coerce consistently), and a MATCHING case application replaces that field's rule set in the bound form's rules store PERSISTENTLY for the page. There are NO standalone `maxLength`/`minLength` engine rules — length constraints are `isString`/`isNumber`/`isInteger` parameters; unknown rule keys are silently skipped. **Autocomplete interception + stale-error clear (former #261 — #B134/#B135/#B136):** the Safari-autocomplete keydown interception (autocomplete="off" fields on a live-check form) (a) bails on modifier chords — `e.metaKey`/`e.ctrlKey` returns BEFORE preventDefault so native select-all/copy/paste/cut/undo run (pre-fix Cmd+A typed the chord letter and keyboard paste was dead — `execCommand("paste")` is inert in unprivileged content); (b) gates on REAL Safari only (`/safari/i && !/chrom(e|ium)/i` — every Chromium UA carries the Safari token; WebKit-on-iOS browsers stay matched by design); and (c) the live-check whole-form pass, when it turns VALID, clears every previously-errored field's rendered error in BOTH copies instead of leaving a stale blocking paragraph beside the re-enabled trigger (the pass still never renders NEW errors on untouched fields). **The async `query` rule (former #264 — #B87 0.5.22; the #B332-#B346 cluster, 0.6.7):** the result path is BLANKET-guarded — a throw while processing the response (a detached target's null `.form` after a popin closed mid-flight, a malformed JSON body, a boolean value hitting `.toLowerCase()`) routes to `releaseQueryWaiter` (warn + release with field state as-is — FAIL-OPEN by design: the verdict is unknown and the server re-validates on submit); `xhr.onload`'s catch and the wrapped `onerror` route to the same release; the release helper is itself try-guarded and names the field. A submit can never complete while the verdict is on the wire, and ONE production POST per click at most: `getOwnedElements` dedups by node identity (an id-less submit trigger used to enter twice and stack TWO listeners — one click ran two full validate() passes, #B333), the async-arm guard is PASS-LOCAL (`armedAsyncFields` — waiters stack per pass and self-detach on first fire; the second pass used to find the first pass's waiter, zero ITS OWN counter and complete on the sync-only verdict ~200ms before the query answered, #B332), the wire dedups via a `ginaFormValidatorQueryPending` dataset marker, and every `validated.<formId>` listener consumes only its own pass's dispatch and detaches (stale listeners used to replay the submit once per leftover, #B334). #B337: the completion payload is the no-arg `d.getErrors()` — the WHOLE pass's verdict (field-scoped payloads meant other invalid fields were adjudicated but never rendered on the submit path, and the inverse scene dispatched an EMPTY error set — refusing the submit with no rendered reason). Engine contract worth knowing: `queryFromFrontend` SKIPS the wire when `self.isValid()` is already false, so whether a query fires on an invalid form depends on FIELD ORDER — the settle/skip completion payload must carry the full verdict either way. #B338: both formerly-synchronous releases (cached verdict, wire skip) defer via `queueMicrotask` so every release takes the post-pass shape a real settle has (they used to fire mid-field-loop, composing a completion missing every field declared after the query field and CLEARING their rendered errors on a re-click). #B342: a latched form's async completion dispatches UNCONDITIONALLY — the waiter's clean path used to dispatch only when the query field happened to be LAST in the rule set, so a clean submit with the query declared earlier starved the callback, no POST ever left, and the `isSubmitting` latch stranded the form until reload (masked pre-arc by the very #B332 double-pass the arc removed). #B346 closed the LAST silent door, one step UPSTREAM — the trigger gate: with live-check on, `updateSubmitTriggerState` gates the trigger whenever the form is not-yet-valid, but while a query is ON THE WIRE the form has NO verdict, and the gate misclassified verdict-pending as invalid — a click inside the round-trip window died silently (consumer-measured 0/6 inside vs 6/6 after; the window scales with query LATENCY, not typing speed). The refusal is narrowed by `isAwaitingQueryVerdictOnly`: the framework's gated mark must be the ONLY refusal arm (an authored `aria-disabled` or non-IDL `disabled` still refuses), an owned field carries the in-flight marker (checked in-form AND on `form="<id>"` reassociated controls, CSS.escape-guarded), and no SETTLED field holds a committed error (the pending field's own provisional `query` entry — staged by the same-value fast path — does not count; counting it refused every window the narrowing exists to recognize, measured). Consulted at the click proxy, the trusted-submit proxy (wrapped-label `<button type="submit"><span>` shapes; Enter with a default button is spec-synthesized into a click on it) and the loading arm; the shared fresh-validate path LATCHES `isSubmitting` when proceeding under a pending verdict (an un-latched async pass STARVES — this was also silently starving programmatic `submit()`) and releases the latch before any consumer `submit.<id>` dispatch, so a stopping custom handler cannot strand the form. **Send lifecycle (former #296 — #B175/#B176 0.6.1, #B192 0.6.3):** every `send()` builds its own LOCAL XHR (the module-scope reuse re-`open()`ed a completed instance, synchronously replaying the PREVIOUS submit's `onreadystatechange` at readyState 1 — submitting form B after form A stranded A's trigger natively-disabled with no release path) and registers a `loadend` release — success, error, timeout and abort alike — removing `disabled`/`aria-disabled` + `data-gina-form-loading` and clearing `$form.isSending`/`$form.sent`; `$form.isSending` genuinely spans send→settled; the timeout path REMOVES `data-gina-form-loading` (never writes the truthy string "false"). #B192: a REJECTED submit releases the `isSubmitting` latch (the only clear was the XHR settle, which a rejected submit never reaches — one invalid attempt latched the flag for the page's life, swallowing every later keystroke; the flag lives on the `$forms[id]` OBJECT so it even survived a full unbind/rebind). Diagnostic: read `gina.validator.$forms['<id>'].isSubmitting` — truthy IS the latched state (exactly what the live-check gate tests); the other discriminator is the live-check request count after a keystroke (0 latched, 1 healthy). ⚠️ **Do NOT use `isValidating` for this** — consumer-A/B-measured NON-discriminating (2026-08-01): it inits null and is set by EVERY validation cycle, so on any form that live-checked before the submit it reads `false` in BOTH the latched and healthy states. #B176: the live-check opt-out is honored consistently — the two gates that evaluated the rules-count boolean INSIDE `/^(true)$/i.test(…)` (an explicit `"false"` short-circuited to the count and matched) now test the attribute alone; the three `registerForLiveChecking` sites always did. Standing caution: `setOptions()` is a MODULE-wide default setter, not per-call. **Loading state (`data-gina-loading` + `lib/loading-state`, 0.6.4-0.6.5):** the FORM-scoped `data-gina-form-loading` says "a request is in flight"; the TRIGGER-scoped `data-gina-loading` marks the submit-like element the user actually operated — `"true"` while its action runs, `"false"` at ANY terminal outcome. Five release paths, two reaching no XHR at all: a submit REJECTED by validation, `send()`'s rate-limit early return, plus `loadend`, readyState 4, and never-arming when the disabled-trigger gate turns the click away. ⚠️ **The `"false"` value CONSTRAINS READERS: the attribute stays PRESENT when released — match on the VALUE, `[data-gina-loading="true"]`, never a bare `[data-gina-loading]`** (which matches a released trigger and pins the loading style on permanently); framework code uses the primitive's `isArmed()` (`=== "true"`). Arming happens at TWO sites because one is not enough: the click proxy (after the disabled-trigger gate, climbing from `event.target` to the owning button — a click on `<button type="submit"><span>Save</span></button>` targets the inner node) and the native submit proxy (where Enter, `form.submit()`, `$validator.submit()` and that same wrapped-label click actually surface). The attribute NAME is configurable — `gina.setOptions({ loadingAttribute: 'data-loading' })` (#B305: `setOptions()` used to write an orphan object nothing read — inert for every key since it shipped; it now merges into the exposed `gina.config` in place; on builds predating the fix use direct assignment `gina.config.loadingAttribute = …`), read LAZILY on every call; deliberately NO auto-detection (a DOM probe reads nothing at rest, a stylesheet probe races late sheets and throws cross-origin, and writing both names stacks spinners). The logic lives in the stateless `lib/loading-state` primitive (aliased into the browser bundle in BOTH build configs, required from `core.js`, intentionally NOT in the `lib/index.js` registry). `resolveTrigger` takes an optional third argument — a PREDICATE saying what counts as a trigger (default: submit-like controls only): the WALK belongs to the primitive, the notion of a trigger to each caller; a predicate that throws is deliberately not caught. ⚠️ Neither popin nor link passes one — both resolve by id/`closest()`, never from a raw click target. **The LINK plugin carries the same one-XHR-per-request repair (0.6.4):** each link request builds its own transport carrying a sequence taken BEFORE the supersede-abort (an `open()` on a busy object implicitly ABORTS it, an aborted request reaches readyState 4 with status 0, and `handleXhr` has no status-0 branch — the first click's completion fired NOTHING); link arms `data-gina-loading` on the anchor inside `linkRequest` (every entry funnels through it) and releases from a `loadend` listener — which fires for success, error, abort and status-0 alike; only an indefinite hang escapes it. The arm is GATED on `xhr.addEventListener` existing (the CORS branch can swap in a legacy `XDomainRequest` — never arm what cannot be released), and the release is deliberately NOT sequence-guarded (the sequence drops a stale RESPONSE; a superseded request must still RELEASE its trigger). Measured, not assumed: `abort()` fires `abort` + `loadend` SYNCHRONOUSLY, so the superseded request releases before the newer click arms. ⚠️ Still open and deliberately so: a genuine network failure remains silent and a hanging link request has no deadline (`xhr.timeout` never set) — both held back as consumer-visible changes for their own release note. **POPIN arms its trigger too (0.6.4):** `armPopinTrigger()` at both arm sites; releases at readyState 4, in `popinClose`, and in `popinUnbind` — the LAST is load-bearing because routing transitions reach `popinUnbind` WITHOUT `popinClose`. ⚠️ `data-gina-popin-loading` (the CONTAINER — "this popin is filling") and `data-gina-loading` (the TRIGGER — "this control is busy") are NOT the same signal; a rename between them is a regression. The formerly-open preload gap is CLOSED (#B285, 0.6.5): a popin opened from an in-flight preload arms via the adopted-preload arm. Maintainer cautions: `popin.test.js:778` pins `showLoadingShell($popin, $el);` at >= 2 LITERAL occurrences (absorbing the arm block into a helper reds it — grep it with `-F`, `$` and `(` are PCRE metacharacters), and `popin.test.js`'s `driveConsume` extract-and-executes the consumePreload bytes with only 7 injected symbols. **Default CSS + the `@layer gina` override contract (0.6.4-0.6.7):** `[data-gina-loading="true"]` gets a `progress` cursor + an opacity pulse gated on `prefers-reduced-motion: no-preference` (a static dim under `reduce` — a traded signal, not none). Two omissions are deliberate: NO pseudo-element spinner (stacks with a consumer's, forces `position: relative`) and NO `pointer-events: none` (suppresses the second click link deliberately supersedes). Default LOOKS (loading + the `data-gina-form-submit-gated` not-ready look) ship inside `@layer gina`, so ANY un-layered project rule overrides them regardless of specificity or load order — load-bearing because gina's stylesheet-injection order relative to project CSS is author-dependent; projects organising their own CSS in layers order themselves with `@layer gina, app;`; NEVER add `!important` inside the layer (important INVERTS layer priority). Functional rules (popin structure, scroll-lock) deliberately stay UN-layered so a generic project reset cannot break the mechanism. Browsers without `@layer` drop the block (attributes still written, project CSS still applies); `animation: none` drops just the motion; renaming via `loadingAttribute` opts out of the stylesheet entirely (keyed on the default name — the gated attribute is NOT renameable). ⚠️ Build gotcha, general to any new gina stylesheet: the CSS phase concatenates ONLY the compiled file whose basename matches its own directory (`foo/sass/bar.scss` compiles but never reaches `gina.min.css`); `inspector` is skipped entirely (served separately). **Labels & rendering (former #297 — #B178 family, 0.6.1):** the key an app can OBSERVE in a field's `errors` object is not always the key the label catalog is consulted under — four rule families write a generic key while consulting a specific label key (the float NaN branch renders `errorLabels['toFloatNAN']` into `errors['toFloat']`; the Length families render `Min`/`Max` label variants into the generic `is<X>Length` keys) — so an ALIAS FILL applies to every app-supplied label layer (server catalog node, client whisper, per-culture registrations, `setErrorLabels()`): a supplied generic fills the specific keys the app did not supply; a supplied specific still wins; English defaults untouched. Numbered `is` aliases with no rule-supplied text fall back to the shared `is` label (translate catalog key `is` once to cover every alias); the user-validator setup loop fills only when the app supplied none (it used to reset every user-defined validator's label to English, clobbering catalog translations). Render-side: the aria-live announcement joins per-message texts with a sentence separator (raw `textContent` concatenated with NO separator — a screen reader received one run-on string), and two rules resolving to byte-identical text render ONCE (the dev inspector still records every error key). All browser-bundled — consumers RE-BAKE at pickup; apps that translated only observable keys will see those messages switch from English to their language. **Transport-layer settle — a submit that never reaches the server now reports (#B447).** An XHR settling at `readyState` 4 with `status` 0 — network down, connection refused, DNS gone, the server process restarting — used to emit NOTHING: the success arm tests `/^2/.test(xhr.status)`, the error arm was `else if (xhr.status != 0)` which EXCLUDES 0, the arm that would have caught 0 sat fully commented out, and there was no trailing `else` — so both `error.<id>` and `error.<id>.hform` were unreachable for exactly the failure `data-gina-form-event-on-submit-error` most needs to cover. The `loadend` fail-safe still released the submit trigger, so the form spun, re-enabled, and said nothing. Both events now fire with `{ status: 408, transportError: true, error: <text> }`: 408 keeps `status >= 400` handlers working, and `transportError` is what distinguishes this from a genuine timeout (which emits a bare 408). The result stashes to `$form.eventData.error`, matching the three sibling arms in the same readyState chain and the key the readers actually consult — NOT the `on*` keys `ontimeout`/`onprogress` write, which are measured write-only (0 readers each) and are never cleared by the pre-validation `delete`. ⚠️ `xhr.timeout` is still never assigned, so the two `ontimeout` handlers remain dead code (#B448, LOW, on-demand — closing it means inventing a client-side timeout config surface that does not exist today); a real network timeout lands on THIS arm instead, which is why #B448 is NOT a coverage hole. Browser-bundled ⇒ consumers RE-BAKE at pickup. Tests: `validator-transport-error.spec.js` (arm 01 is a POSITIVE CONTROL — a 500 MUST fire the error event, without which arm 02's silence proves nothing; the `error.<id>.hform` channel is deliberately NOT asserted, because declaring the attribute flips `listenToXhrEvents` on and would change the shared fixture's scene) + `validator-autocomplete-interception.test.js` / `validator-livecheck-stale-error-clear.test.js` (extracted-real-bytes + red-first dist pins — Closure De-Morgans the chord bail, so minified pins target property-name shapes, never var names) + the #B332-cluster e2e matrix (incl. the field-order arm: a clean form whose query field is NOT last must send exactly once, after the settle). **Server-side twin — the `query` rule must not mutate the shared `app.proxy` conf (#B522, 0.6.30):** `queryFromBackend` bound its request options straight to `getConfig(<bundle>, 'app').proxy[<target>]` — the GLOBAL two-argument `getConfig` returns the bundle conf BY REFERENCE (`resolveBundlesConf` hands back `conf.bundlesConfiguration.conf` / `conf.envConf` with no clone) — then wrote `opt.method` and `opt.path = route.url` into it, while `controller.query()` added `requestTimeout` from the calling route's `queryTimeout` BEFORE its own `merge(JSON.clone(options), …)`. All three persisted on `content.app.proxy[<target>]` for the process lifetime. ⚠️ `path` and `requestTimeout` are DOCUMENTED `proxyTarget` properties (`schema/app.json`), so the shared node carried a clobbered `path` and a stale `requestTimeout`, not merely stray keys. ⚠️ Be precise about WHICH key does harm: the clobbered `path` is OBSERVABLE to every reader of that conf but has NO framework CONSUMER — `checkBundleStatus` overwrites `opt.path` with its own `route.url` one line after reading the object, `downloadFromURL`'s protocol-upgrade loop never reads `path`, and nothing prepends a configured `proxyTarget.path` (`controller.query()` uses `options.path` AS the request path). The key that IS consumed is `requestTimeout`: once a stale value sits on the shared target the guard `typeof options.requestTimeout === 'undefined'` cannot fire, so a later route's declared `queryTimeout` is silently ignored — MEASURED, a 9s route governed by a polluted 1s (Δ8994 ms against an unpolluted control) — and on HTTP/2 the timeout EVICTS AND DESTROYS the pooled session for that authority (measured shared: two requests reuse one client object) before re-issuing up to 3×, so one route's live-check degrades traffic that never touched the bug. Fixed by cloning at the single mutating bind (`JSON.clone(...)`, a real recursive deep clone, not `JSON.parse(JSON.stringify(...))`); an `app.proxy` census found THREE write sites but only ONE onto the SHARED object. The other two — `controller.js:3757-3758` (`opt.method`/`opt.path` in `checkBundleStatus`) and `:3871-3873` (`bundleObj.host`/`.hostname`/`.port`, inside `downloadFromURL`'s protocol-upgrade loop) — cites re-measured on HEAD 2026-09-10, the former `:3694-3695`/`:3808-3810` having drifted — write just as freely and are safe for ONE reason: they go through the CONTROLLER's `getConfig`, whose copy-on-write view (or the `controller.getConfig.mode: 'clone'` opt-out) captures the write in a per-call overlay. ⚠️ **That is the rule to carry away: whether a write to a conf object is safe depends on WHICH `getConfig` produced it — the global two-argument one returns the live node by reference, the controller's returns a view or a clone — never on how the write looks.** ⚠️ And the view is ONE-DIRECTIONAL: it stops writes reaching the shared tree but does NOT sanitise reads, so before this fix the `checkBundleStatus` reader observed the clobbered `path` even through its view (`lib/conf-view/src/main.js:228-230` returns a non-plain value straight off the shared target; `materialize` at `:176-188` copies FROM it, so `Object.keys` / `JSON.stringify` / `JSON.clone` of a view all fold stray keys in). The read-only sibling one line above the fix was itself dead code and is REMOVED (#B536, 0.6.30) — a second by-reference grab of the shared bundle `app` conf that fed nothing; verified by the identifier reading 0 in the source with firing controls, and the verbatim r.js bundle no longer carries it. The FRONTEND twin never touches proxy conf and is unaffected. ⚠️ Pickup = restart AND RE-BAKE even though the changed branch is server-only: the file is browser-bundled (`build.json` aliases `lib/form-validator`), so `gina.min.js` moves — measured by identity, with `gina.min.css` byte-identical as the firing control. Test: `validator-proxy-conf-clone-b522.test.js` drives the REAL shipped module exactly as the router does, through the sanctioned `setContext('__mock__', { config })` hook, and its §02 arm is a CAN-FAIL instrument check — the stub server must actually receive the route url, without which the pollution assertions could pass vacuously by never reaching the write site at all. A pre-existing false-positive warning on that path is FIXED at its producer (#B537, 0.6.30): `getRoute()` resolves a req-less caller's proxied classification from the `isProxyHost` context latch, whose only boot-time writer runs when the proxy configuration resolved a record for the running scope and env — a bundle whose `proxy.json` carries no such record boots with it unset, the fallback read returned `undefined`, and the server-side `JSON.clone` flagged the non-boolean key on every clone. The fallback now coerces with `|| false`, the idiom five sibling readers of the same latch already use. No consumer distinguishes `undefined` from `false` — every read is truthiness-only, and the `getUrl` filters overwrite the flag before reading it — so the change is invisible except for the warning; on the request path the value was already a strict boolean from the request store, so the `undefined` reaches only a req-less caller, and the framework has no req-less `getRoute()` caller of its own (application code only). Pinned by `routing-isproxyhost-boolean-b537.test.js`: real-module drives, a frozen pre-fix copy compiled in-memory as the subtract control, and a can-fail warn-capture arm. Pickup: restart; `gina.min.js` changes (identity-measured, `gina.min.css` byte-identical) but the client branch never produced the value and the client clone never warned, so the re-bake is byte parity. **#gh76 slice 2 — the submit pipeline owns the `text/html` answer contract (0.6.32).** A form places its own answer with three attributes resolved AT SUBMIT from the form (never from the page state at settle): `data-gina-form-target` (hx-target grammar — `this` | `closest <sel>` (ancestor-OR-self) | `find <sel>` | a document-scoped CSS selector; `next`/`previous` RESERVED and refused so adding them later stays additive), `data-gina-form-swap` (nine htmx strategies, `innerHTML` default) and `data-gina-form-select` (ALL matches, document order). A declared target WINS over the popin the form is in (#213); absent, the popin path is unchanged. Refusal is FAIL-LOUD and happens BEFORE `xhr.open` — unresolvable target, invalid selector, reserved/unknown form, unknown strategy ⇒ `error.<id>` (+ `.hform`) with `{status:422, reason:'targetError', attribute, value, transportError:false}`, `isSending`/`sent` cleared, the trigger released, NO request sent — deliberately the inverse of `data-gina-dialog-target`'s fallback to a full replace (silent in production, announced in dev mode since #B580), because a submit has already changed server state. ⚠️ That refusal is a FOURTH loading-state terminal path and is OWNERSHIP-GATED (`ownedByEarlierSend`, read BEFORE `send()` claims `isSending`): with `withRateLimit:false` a second attempt can be refused while the first still runs, and both the release and `isSending` belong to that first request — `armSubmitLoading` is first-wins, so the refused attempt armed nothing of its own (the #B247 rule; `loading-state.test.js §06` counts the sites AND pins the guard — it caught this releasing unconditionally). Success payload = the legacy `{contentType, content, status}` PLUS `target`/`swap`/`swapped`/`data`/`view`; `swapped:false` is not an error (`swap:'none'`, a `select` matching nothing, or a target detached in flight). Events `beforeswap` (cancelable via `preventDefault()`, `detail.content` re-read after dispatch so a listener may rewrite it — needs the `on()` wrapper's name-scoped `cancelEvent` exception for `beforeswap.` only) and `afterswap` (after the region is bound; declarative hook `data-gina-form-event-on-swap`). The swapped region is bound through the SHARED `bindRegion()` policy (utils/dom, #213). ⚠️ The validator declared its OWN module-local `bindRegion` for the forms half: `var` HOISTS over the whole file, so it shadowed the shared global and `applySwap` silently bound forms while never re-creating a swapped region's `<script src>` — the local is `bindFormsInRegion`, the PUBLISHED `gina.validator.bindRegion` name unchanged, and `region-binding.test.js §02` now pins the ABSENCE of a local `bindRegion`. A swap replacing the submitting form (`outerHTML`/`delete`) keeps its listeners through the success events, then binds the same-id replacement (`deferFormId`). Pinned by `validator-form-target.test.js` (27) + `validator-form-target.spec.js` (18 e2e arms). Browser-bundled ⇒ restart AND re-bake. **#gh76 slice 3 — out-of-band swaps (0.6.32).** Any element of the answer carrying `data-gina-swap-oob` is swapped into the PAGE element with the same `id`, independently of where the answer itself goes (htmx's `hx-swap-oob`). `true`/empty ⇒ `outerHTML` (the element ITSELF, attribute stripped on landing); a strategy name ⇒ the element's CONTENT with the wrapper dropped (`innerHTML` would otherwise nest a duplicate id); `<strategy>:<selector>` RESERVED. Every candidate is REMOVED from the answer whether or not it swapped — the main fragment is always clean — and the whole pass is STRING-GATED on `result.content.indexOf('data-gina-swap-oob') > -1`, so an answer without the attribute takes byte-identical paths (the gate is pinned at exactly 3 occurrences). Reached from all THREE html paths: `applySwap` (BEFORE `select` narrows, so an element outside the selection still lands; where both address the same element the main swap wins), the popin branch, and the legacy target-less tail — so an answer carrying nothing else still updates the page, htmx's `hx-swap="none"` rule. The payload gains `oob` (one entry per element, `{id, strategy, swapped}` plus `reason` for `noId`/`noTarget`/`reserved`/`unknownStrategy`/`cancelled`) and `remainder` (the answer minus them — what a handler should insert, since `content` stays RAW and would land them twice); both are ABSENT below the gate, so `'oob' in payload` means "the answer carried the attribute". Events fire PER ELEMENT: `oobbeforeswap.<id>` (cancelable, `detail.content` rewritable) and `oobafterswap.<id>` (+ its `.hform` companion when armed). ⚠️ `oobbeforeswap` has NO `.hform` twin, mirroring `beforeswap`: a cancel is a DECISION while the declared-callback channel carries notifications — and the `on()` wrapper's cancelEvent exception had to widen from `/^beforeswap\./` to `/^(oob)?beforeswap\./` or the cancel never reaches a `.on()` listener at all. Three traps worth carrying. (1) An author-supplied `<template>` is honoured (htmx parity), and one EMPTIED of its oob elements is REMOVED with them — otherwise an oob-only answer leaves `<template></template>`, whose non-empty remainder makes the popin path call `loadContent` and BLANK the open dialog; measured, `loadContent('')` throws nothing, keeps `isOpen`, fires `open`+`loaded`, and leaves a blank dialog whose form is UNBOUND, which is exactly why the empty-remainder skip exists. (2) `textContent` takes the REMAINDER, not the raw answer, or the consumed transport renders as visible text. (3) A nested oob inside ANOTHER oob is not processed on its own — it travels with its ancestor and its attribute is stripped there (htmx's `allowNestedOobSwaps:false` parity), while one merely nested in a NON-oob wrapper is processed and removed from it. An oob swap that replaces or removes the SUBMITTING form defers its binding (`deferFormId`) and reports `rebindSelf`, finalized by the shared tail — the popin branch RETURNS before that tail, so it finalizes itself, and the main swap's flag became `sendCtx.rebindSelf || detachesForm` so it can no longer clear a flag an oob swap already set. The same two test files now carry 50 unit arms + 26 e2e arms. Browser-bundled ⇒ restart AND re-bake. **#gh76 §6 — the server's last word (0.6.32):** three RESPONSE headers, read at settle inside the `text/html` branch (a JSON answer never reaches the read), after the answer is known to be HTML and BEFORE the target fork and `beforeswap` (htmx's order — a listener keeps the final say): `X-Gina-Retarget` (the `data-gina-form-target` grammar through the same `resolveSwapTarget`; it CREATES a target where none was declared, replaces a declared one, and `sendCtx.targetAttr` names the header — a declaration made by the server, so it wins over the popin exactly as an attribute does), `X-Gina-Reswap` (one of `SWAP_STRATEGIES`) and `X-Gina-Reselect` (a selector). The invalid-value rule is ASYMMETRIC, each half a rule that already existed: a Retarget that cannot be resolved DROPS the target — no swap, not into the declared target and not into the popin (the server plainly meant somewhere else) — and the success payload is the swap shape with `swapped: false`, `reason: 'retargetError'` and `target: null`, never an error callback (the false-422 shape slice 1 removed); a Reswap/Reselect that fails validation is IGNORED and the declared value kept (htmx degrades an invalid `HX-Reswap` the same way), as is either one without any target (`reason: 'noTarget'`). The report — `sendCtx.overrides`, one `{ value, applied, reason }` entry per header — rides the payload (`overrides`, present only when a header was sent), the `beforeswap` detail, and the legacy target-less payload; the popin path's parsed data stays VERBATIM (the slice-1 contract), so an ignored override there is a dev-mode console notice only. SAME-ORIGIN ONLY: the three headers are honoured only from a response whose origin is the page's own (`xhr.responseURL` — the URL after redirects — against `location.origin`); a cross-origin Retarget is refused like an unresolvable one (no swap, `reason: 'crossOrigin'`), a cross-origin Reswap/Reselect is ignored and the declared value kept, and a missing or unparseable `responseURL` or an opaque origin on either side reads as not-same-origin (fail-closed) — htmx needs no such read because `selfRequestsOnly` refuses the cross-origin REQUEST, while a form here posts to its raw `action`, so the gate lives at the read, and `Access-Control-Expose-Headers` buys nothing for these three; no controller helper — `self.getResponseObject().setHeader()` is the documented way. `applyResponseOverrides(xhr, sendCtx, $target, id)` is extractable (unit seam), pinned to exactly ONE call site inside the html branch. **Request coordination (#gh76 §7, UNRELEASED):** the DEFAULT is DERIVED and needs NO attribute — `deriveSync($form)` reads `data-gina-form-target` + `data-gina-form-swap` and returns `{strategy:'replace', derived:true, key, swap}` ONLY when the swap is in `REPLACING_SWAPS` (`innerHTML`/`outerHTML`/`textContent`/`delete`) AND the target RESOLVES; an insertion, `none`, no target, an unresolvable target, or a swap the pre-flight would refuse, all return `null` (no coordination at all, today's behaviour). The swap is read with `.trim()` only — CASE-SENSITIVE, matching the pre-flight's `SWAP_STRATEGIES.indexOf` — so a value heading for a refusal can never abort a running request on the way. `data-gina-form-sync` is the OVERRIDE (`drop`|`replace`|`queue`, no modifiers); `abort`, `queue <mod>` and `<selector>:<strategy>` are REFUSED with a message naming the reason AND the alternative (deliberate non-adoptions, not gaps: `abort` is htmx's disposable-GET idiom and a form submit is a validated POST with side effects; the `queue` modifiers exist in htmx because its trigger spec has `queue:` modifiers of its own; `<selector>:<strategy>` names a key that is already known). ⚠️ A SUPERSEDE IS NOT A ROLLBACK: the aborted request was already in flight, so it may have REACHED the server and its writes stand — a consumer must never read `abort`/`superseded` as 'not saved' and RESUBMIT (a duplicate write, on a feature whose premise is that a submit is a POST with side effects); the outcome of a superseded submit is the server's to report, never the event's. ⚠️ ORDERING IS LOAD-BEARING TWICE OVER: the derivation runs AFTER the module-wide `withRateLimit` gate (so one form's own re-submits keep the one-at-a-time rule and the derived default only ever coordinates DIFFERENT forms — zero blast radius on a single form), and the whole decision runs BEFORE `instance.$forms[id].isSending = true`, because `xhr.abort()` settles the aborted handler SYNCHRONOUSLY — claiming first lets that settle clear the flag this send just took (#B332 class). Same reason the handler-top `$form.isSubmitting = false` is guarded on `!sendCtx.superseded`. Keyed on the RESOLVED SWAP TARGET (`resolveSyncKey` for the explicit path; the derived path carries its own `syncParsed.key`) — a form key would not see the case the issue is about, two DIFFERENT forms answering into one region. Registry = a module `WeakMap<Element,{xhr,ctx,queue}>` (a swapped-away target is a new element, so its entry is collected — correct); NO seq counter, the per-send XHR object (#B175) IS the identity a late settle is checked against; NO `abortable` flag — the ARRIVING submit's strategy decides and nothing is carried on the entry. A SUPERSEDED request runs its RELEASE arms (trigger, loading state, a11y) then RETURNS before the status dispatch, emitting `abort.<id>` `{status:0, reason:'superseded', sync, derived}` — it must never reach the #B447 transport arm, which reads readyState-4/status-0 as a 408 the server never sent; there is deliberately NO `.hform` twin (nothing went wrong). `derived` is ON THE PAYLOAD because the default needs no attribute, so a page may find nothing in its own markup that asked for the abort. `replace` re-arms `armSubmitLoading` after the abort released it (first-arm-wins, stash just cleared). Exactly ONE turn-away exists (`drop`), so `disarmSubmitLoading` counts 6 in the file (1 JSDoc + 5 sites, pinned in `test/lib/loading-state.test.js`) — `queue` WAITS and keeps its loading state (pending, not abandoned; the replay's first-arm-wins carries it to the real settle), `replace` proceeds. An UNREADABLE sync value decides nothing and its refusal is DEFERRED to the pre-flight: from the top of send() the declared callbacks are not bound yet (`listenToXhrEvents` runs later), so a refusal there reaches nobody — measured, it silently delivered no error; a DERIVED result never carries an error by construction. `data-gina-form-disabled-elt` = a comma list in the target grammar, resolved after every refusal and before `xhr.open`, refcounted in a second WeakMap, marked `data-gina-disabled-by`, released at the `loadend` fail-safe (the one chokepoint covering error/abort/timeout) PLUS the binary branch's error exit, which never reaches the wire; an element the PAGE disabled is skipped and never counted. Both refusals reuse `refuseSend`, so `reason` stays `'targetError'` and `attribute` discriminates. The slot is claimed on the line AFTER each of the three `xhr.send()` sites — claiming earlier wedges a key forever on the one path that opens an XHR and never sends it. `parseSync`/`deriveSync`/`decideSync`/`queueSyncSend`/`shiftSyncQueue`/`resolveDisabledElts` are extractable (unit seam). ⚠️ Instrument: `input.disabled` reflects only the element's OWN attribute — a control disabled by a `<fieldset>` ancestor is observable through `:disabled`, not `.disabled`. ⚠️ Instrument: Closure renames `deriveSync`/`REPLACING_SWAPS` out of the MINIFIED bundle, so the discriminating content probe between a pre-reframe and a reframed `gina.min.js` is `abortable` (9 → 0); the readable names survive only in the unminified `dist/.../gina.js`. ⚠️ #B247's ownership rule had to be RE-DERIVED for a target key and the gap was found by a cold read, then DRIVEN: the module-wide gate may return without releasing because it only ever fires on the form that already owns the request, whose settle releases it — but a TARGET key turns away a DIFFERENT form, whose armed `data-gina-loading` nothing else would ever clear (measured: stranded `true` on the real bundle). ⚠️ **A template writing `data-gina-form-sync` must write the WHOLE attribute conditionally, never an empty one.** The gate handing a form its own overlap decision is `syncAttr === null` (`core/plugins/lib/validator/src/main.js:3174`), so a present-but-empty `data-gina-form-sync=""` is NOT absent: it passes the gate, reaches `parseSync('')` → `{error:'empty value'}` (`:913`) and REFUSES the submit before anything is sent — the same fail-loud path an unknown strategy takes. Swig-safe shape: `{% if page.view.params and page.view.params.sync %} data-gina-form-sync="{{ page.view.params.sync }}"{% endif %}`, and the `page.view.params and` half is LOAD-BEARING because `page.view.params` is set only `if (ownCount(parameters) > 0)` (`core/controller/controller.js:1432`) — i.e. it is ABSENT on a route reached with no parameters, so testing `page.view.params.sync` alone throws there.
|
|
1051
1051
|
302. **The `stream` storage strategy (`lib/storage/src/local-stream`, #STO1) — large sequential media with RESUMABLE uploads.** Keys name an ASSET, not a file: `assets/<ulid>/original<ext>` — `original` is the reserved base-rendition name and the per-asset directory reserves the namespace future renditions land in (no rendition API in this release). Keys stay OPAQUE; never parse the directory back out. What per-asset colocation buys is OPERATIONAL grouping (one `rm -rf` per asset, one `rsync` of a subtree), NOT physical locality — a directory does not place extents, the allocator does — and gina neither prevents nor CAN prevent fragmentation: Node exposes no `fallocate` binding, native addons are not shipped, and `ftruncate` yields a fully SPARSE file (measured: a 256MB truncate reserves 0 blocks, and truncating past the free space succeeds, so it delivers neither anti-fragmentation nor an early ENOSPC). Contiguity is a filesystem/volume concern (XFS extent hints and the like); `chunkSize` (default `'8MB'`, `parseSize`) is the write stream's `highWaterMark` and nothing more. **Five capability-gated verbs beyond the shared contract** (`capabilities.resumable` true — branch on the flag, never probe): `createUpload(meta, cb)` -> `{uploadId, chunkSize, expectedSize}` / `writeSegment(uploadId, offset, stream, cb)` -> `{offset, length, received}` / `statUpload(uploadId, cb)` -> `{expectedSize, contentType, originalName, createdAt, received[], missing[], complete}` / `finalize(uploadId, cb)` -> `{key, size, contentType}` (put()'s shape) / `abortUpload(uploadId, cb)` -> `{aborted}`, idempotent. `statUpload` is NOT optional — resume is impossible unless a client can learn what landed; it is the resumable twin of `stat()`, and being computed from the session's own files it stays accurate across a process restart with no in-memory session table. **`expectedSize` is REQUIRED at create**: without a declared total finalise cannot verify coverage and `statUpload` cannot say what is MISSING (only what arrived), so a caller who does not know the size uses `put()` — the same exclusion tus takes without its defer-length extension, and strictly more useful than S3 multipart's `ListParts`, which can never name a missing part because it never learns the total. The declared size also permits an early `maxObjectSize` refusal before any byte moves, **advisory only**: it is client-supplied, so the running byte counter in `writeSegment` (which refuses a segment running past the declared end) is the real enforcement. **The KEY is minted at `createUpload` and persisted in the manifest**, never invented at finalise — minting it late means a row write that fails after the bytes are published leaves them at an address nobody holds, an unreachable orphan PLUS a session that can never finalise again. **The filesystem IS the manifest**, and the seam gains ZERO new verbs: a session is `<root>/.uploads/<uploadId>/` holding `data` (assembled IN PLACE via positional writes, `flags:'r+'` + `start`, so segments may arrive out of order and no assembly pass runs at finalise — measured, a segment-per-file layout costs ~1.9 s/GB of extra concat I/O where this costs ~4ms), `meta.json` (written ONCE through temp+rename) and one zero-byte `<offset>-<length>.ok` marker per DURABLE range. `.uploads` is a dot segment, structurally uncollidable with any caller key (the key guards reject dot segments), like `.tmp` and the `.driver` stamp. The argument for that home is FATE-SHARING, not seam economy: `fsync(data)` happens-before the marker is created, which is only enforceable while both live on the same medium — a store-backed manifest (SQLite WAL, redis, couchbase) can survive a crash the bytes did not, and a manifest claiming a range the bytes lost passes coverage and publishes ZEROS. **Coverage is an INTERVAL UNION, and that is the whole safety argument.** A hole in a sparse file reads back as ZEROS rather than erroring (measured), so an unverified finalise publishes a plausible, silently corrupt object; and SUMMING marker lengths is not enough — markers `0-800` and `400-1200` sum to exactly a declared 1600 while `1200-1600` is uncovered. So finalise sorts the markers, merges overlapping AND adjacent spans, and requires exactly one span equal to `[0, expectedSize)`. A gap is a REAL error and the session is PRESERVED so the client can complete it, never a silent publish; the message names the first gap. A segment that ends early is not an error — its marker records the RECEIVED length only, so the union stays exact and re-sends may overlap freely (re-sending a covered range is idempotent). Two further finalise guards: a data file SHORTER than the declared size under a full marker set refuses the publish (marker/bytes desync — the one shape that would still publish zeros), and one LONGER is truncated back (unreachable through the API, so it means tampering). **`finalize` is IDEMPOTENT with a heal path**: if the final path already exists it skips assembly, (re)writes the row and cleans the session — so a finalise that published the bytes and then failed on the row is completed by simply retrying it. Bytes are published BEFORE the row deliberately (the inverse manufactures rows-without-files, which cas's verify classifies as loss evidence and never auto-fixes); the residue here is the benign kind — `get()` falls through to the file path when no row exists. **Durability: `fsync` defaults TRUE, and only the DATA fsync is load-bearing** — per segment the order is positional write -> `fsync(data)` -> create marker, so a surviving marker implies durable bytes; the marker's own durability is deliberately NOT guaranteed, since a lost marker beside present bytes costs a re-send, which is the safe direction. Measured: a process SIGKILLed after an un-fsynced write still yields the full bytes to a later reader (the page cache carries them), so markers stay honest across PROCESS death without fsync — only power loss or a kernel panic opens the window. The cost is a RANGE, not one flattering figure: ~12ms per 8MB segment is ~2% of network-paced ingest at 100 Mbps, ~19% at 1 Gbps, and fsync-DOMINANT at 10 Gbps, so a LAN-ingest consumer sets `fsync:false` and accepts the documented window. The parent-directory flush is best-effort (Windows and some network mounts cannot), and macOS honours fsync as the platform defines it — the `F_FULLFSYNC` caveat cas already documents. **Abandoned sessions get their OWN age-gated sweep** on two triggers — at driver BUILD (what reclaims whatever a crashed process left behind) and on an unref'd `setInterval` — because sessions legitimately live for DAYS where a crashed put()'s temp is dead immediately. A REFUSED put() (over `maxObjectSize`, or a source that errors) does not rely on that sweep at all: its cleanup waits for the write stream's `'close'` and the CALLBACK WAITS WITH IT, so a caller told the put failed can never observe residue (#B358). Do not "simplify" that back to a synchronous `existsSync`-gated unlink — `fs.createWriteStream()` opens ASYNCHRONOUSLY, so a refusal raised from the `'data'` handler runs while the `open(2)` is still in the threadpool: `existsSync` reads false, the unlink is skipped, and the worker thread creates the file a tick later, with NO event-loop turn in between for a synchronous check to catch it (measured ~30% orphan rate before the fix). **ALL THREE local strategies carry this fix** — `sharded` and `cas` build their write stream lazily inside `spill()`, so their window opens on the OTHER arm (a source error arriving just after the spill, rather than a `maxObjectSize` refusal), which is exactly how a first pass that measured only the refusal arm wrongly concluded they were immune. Fault-injecting the single false `existsSync` answer orphans a real temp on both. Two arms reach this handler — the `'data'` refusal and `stream.on('error')` — and a measurement covering one licenses no claim about the other. `sessionTtl` default `'24h'`, `sessionSweepInterval` default `'1h'`, both `parseDuration` (unit required); `'0s'` disables the interval only, and a ZERO ttl snaps back to the default rather than meaning "off", since it would reclaim in-flight uploads. Liveness is `mtime(data)`, NOT the session directory's — `data` advances on every segment write while a directory mtime only moves when entries are added or removed — with a dir-mtime fallback so a session whose data was renamed away by a half-finished finalise stays reclaimable. The same pass reclaims `<root>/.tmp` orphans from crashed `put()`s at the same cutoff (sharded has that gap and no such sweep). **No tiering, no hashing, by design**: `inlineThreshold` and `hash` are NOT in `STRATEGY_KEYS.stream`, so setting either warns as an ignored key rather than silently doing nothing, and `capabilities` reads `{offload:false, ranges:true, dedup:false, resumable:true, inline:false}`. `release()` unlinks the object and then rmdirs the asset directory, which succeeds only while it is empty — a future rendition beside `original` correctly keeps it. **Not in this release, each still deferred or capability-gated**: a rendition API; async post-hoc checksums; an `s3` adapter; and any `storage:gc`/`storage:verify`/stats extension for stream — those stay cas-only, so a stream driver has NO `sweepNow`/`verify` and is named-and-skipped by the maintenance surface rather than erroring. Multi-process is DOCUMENTED, not enforced: parallel segment writes are safe when ranges are DISJOINT (measured — two uncoordinated processes interleaving 1MB stripes into one shared file produced byte-exact output on a POSIX-coherent local filesystem), but relaxed-coherence network mounts are UNMEASURED, so route one session's segments to one process there. Shipping `stream` also EMPTIED `DEFERRED_STRATEGIES` — the list and its boot branch stay (cheap, and the next designed-before-implemented name inherits a working message) but are now exercised by nothing, which is recorded rather than hidden.
|
|
1052
1052
|
303. HTTP Range serving for stored objects — `self.serveFromStorage(driverName, key[, opts])` is the read-side companion of `self.store()`'s driver routing and the consumer `capabilities.ranges` was waiting for: a controller action serves a stored object with the whole HTTP protocol dance owned by the framework, identically on both engines (headers and status ride the response object, bytes ride `renderStream`'s two arms — no `/_gina` endpoint involved). The flow is `stat()`-gated: an unknown or released key answers 404 through `throwError` (cas hides zero-ref rows from `stat`, so a released blob 404s by construction; on `stream` a finalize-heal residue whose metadata row write failed is readable at the driver but 404s here until the idempotent finalize heals the row), and a missing/unconfigured DRIVER is an app config error — 500, never 404. Validators are minted from the layer's own invariants: `ETag: "<key>"` is deliberately STRONG because storage keys are immutable (every strategy publishes via temp+rename, cas is content-addressed, and no in-place mutation API exists), plus `Last-Modified` from the publish time; `If-None-Match` is weak-compared against that ETag (a `W/`-prefixed echo matches) and answers 304 with no driver read. Range evaluation is GET-only and capability-gated (`driver.capabilities.ranges` false ⇒ the header is ignored and no `Accept-Ranges` is advertised — the forward seam for offloading adapters): a single `bytes=a-b`/`a-`/`-n` that is satisfiable answers 206 with `Content-Range: bytes a-b/<size>` and an exact `Content-Length`; an unsatisfiable one (start at or past the size, a `-0` suffix, any range against an empty object) answers 416 with `Content-Range: bytes */<size>` and an empty body; multi-range lists, foreign units, syntactic garbage and `a-b` with a>b are IGNORED into the full 200, which RFC 9110 sanctions; `If-Range` honours the Range only on an exact validator match, so anything unevaluable degrades fail-safe to the full body rather than risking a corrupted client-side assembly. Trust model, fail-closed: the stored contentType is UPLOADER-supplied verbatim, so without `opts.contentType` the active-content types (html/xml/svg/javascript/ecmascript) downgrade to `application/octet-stream` — nosniff cannot stop a DECLARED `text/html` from rendering — and every facade response carries `X-Content-Type-Options: nosniff`; an explicit `opts.contentType` is the app's informed choice and serves verbatim. Caching defaults to `Cache-Control: private, max-age=31536000, immutable` (correct because a key's bytes can never change; `private` keeps shared caches out of the application's authorization), with `opts.cacheControl` winning verbatim; `opts.download`/`opts.filename` emit an RFC 6266 attachment disposition with control characters stripped from the uploader-supplied name (header-injection guard — Node's setHeader would otherwise THROW on a CR/LF-bearing stored originalName, inside an async callback). HEAD answers headers-only with full-size accounting and no driver read. Headers are applied only in the driver-success callback, so a read error routes through `throwError` with no Range headers leaked; a post-stat race (cas grace expiry mid-download, a vanished file) maps the read verbs' `STORAGE_NO_OBJECT` code to 404 and anything else to 500 — the machine codes (`STORAGE_NO_OBJECT`/`STORAGE_RANGE_UNSATISFIABLE`/`STORAGE_INVALID_RANGE`, minted by `util.codedError` with message wording byte-unchanged) shipped for exactly this discrimination. The enablers landed in the same release on `renderStream`: non-SSE Buffer chunks pass through byte-exact (the historical unconditional utf8 decode substituted U+FFFD into binary payloads, which made the delegate unusable for byte serving; valid-UTF-8 re-encodes identically and SSE keeps its decode), HEAD parity (headers-only, the iterable never consumed, a destroyable source destroy()ed), caller-set headers surviving the delegate defaults (the h2 frame literal used to beat the pending merge and the h1 setHeader clobbered — a serving caller's Cache-Control/Content-Range now ride), and the post-end `headersSent` bookkeeping guarded (getter-only on both real response classes under the file's strict mode — the swallowed throw had killed the Inspector Flow stream-write/total timeline entries on every streamed request). Deliberately NOT here: multipart/byteranges (multi-range ⇒ full 200), Range on statics (the pure `_parseRangeHeader(header, size)` helper is reusable when that arc comes), and Range on non-GET methods. Offload serving SHIPPED with the s3 adapter (#304): `capabilities.offload` ⇒ GET/HEAD answer 307 to a presigned URL after the local 304 check, and the provider serves Range natively. Tests: `test/core/serve-from-storage.test.js` (parser matrix + fail-closed contentType matrix as extract-and-execute of the shipped bytes; the protocol arms driven end-to-end on REAL drivers across sharded/cas/stream with byte-exact 200/206 assertions) + `test/core/render-stream.test.js` §13-§16 + `test/lib/storage-error-codes.test.js`.
|
|
@@ -1056,7 +1056,7 @@ Dev-mode query instrumentation captures every database query tied to the current
|
|
|
1056
1056
|
306. **Per-scope bundle deployment — `manifest.json` `bundles[<name>].scopes` (#B373, shipped in 0.6.9).** A registered bundle used to be deployed in EVERY scope: the only exclusion was leaving it out of `manifest.bundles`, which removes it from all of them. `scopes` is an OPTIONAL allow-list with the same semantics as a route's rule-level `scopes` — **absent or null means every scope** (so every existing manifest is unchanged), `["local"]` means that scope only, `[]` parks the bundle everywhere, and a NON-ARRAY is a named manifest error rather than a silent "no scopes" (the predicate returns false for both, so reporting a type error as "not deployed" would send the operator hunting the wrong thing). ⚠️ **Deleting `releases[<scope>]` by hand is NOT a substitute and never was** — measured: BOTH build verbs walk every project scope and re-seed any missing `releases[scope]` + `target`, so a hand-deleted entry regrows on the next build even one built for a different scope; absence cannot carry intent, which is why this is a key and not a convention. A boolean inside `releases[<scope>]` was also refused: `bundle:copy` and `bundle:rename` both `for (var env in entry.releases[scope])`, so it would be iterated as an environment. **Skip vs refuse is split by whether the exclusion was ASKED FOR:** the boot loop and `project:build` (bulk) SKIP an excluded bundle with a notice naming the scope and the remedy — one parked bundle must not block a project build, and a bundle vanishing silently is the #B183 lesson; the STARTING bundle and `bundle:build <name> --scope=X` (an explicit single target) REFUSE by name, because a silent no-op on a deploy script reads as success. The starting-bundle refusal is deliberately placed BEFORE the apps loop: skipping it instead would kill the boot further down in the #B181(b) env.json guard, whose message describes an entirely different cause. The predicate is DUPLICATED in `core/gna.js` because mount resolution runs before Config exists and cannot reach that closure — a test pins the two copies as behaviourally identical, since drift would mean a bundle skipped at one layer and mounted at the other. Both gna sites mattered: the CLI mount deref sits OUTSIDE any try (an excluded starting bundle died there as an opaque TypeError before Config's named refusal could run), and the mount-resolution loop's deref sits in a try whose catch ABORTS the whole mount (so one excluded sibling would have taken the process down at any non-dev scope). Seeding is filtered in both build verbs, which is what makes the opt-out durable. Documented in `schema/manifest.json` (optional, never required) and its docs-site copy. Rule the shape encodes: when a config key already exists whose ABSENCE you are tempted to overload as intent, check who WRITES it — a value your own tooling re-creates cannot express a decision. **The declaration also survives `project:add`/`project:import` (#B375 + #B376, fixed in the same release as this feature, 0.6.9).** Those verbs used to carry a 2023-era count-mismatch reset: whenever a project's DECLARED bundle count differed from a readdir of `<project>/src` (else `/releases`), the whole `manifest.bundles` block was emptied and written back — for EVERY registered project, not just the target — then only the import target was rebuilt from a six-key template carrying no `scopes`, no `gina_version` and no custom keys, with `version`/`tag`/release targets reset to defaults. A bystander project got NO rebuild (a permanently empty `bundles` block that fails its next boot), and plain `project:add` never rebuilds at all. The reset is retired (kept commented in `lib/cmd/helper.js` per the replace-don't-delete convention) and the end-of-loadAssets rescan now runs as a UNION for the current project: manifest-declared bundles keep their entries verbatim, disk-only bundles are appended for the additive #B55 seeding, and a declared bundle whose tree is absent is WARNED about once per command (naming bundle + scanned location) — never auto-pruned, because the declaration may be precisely this entry's scopes restriction; removal stays `gina bundle:remove`'s job. The rescan also derives each bundle's `configPaths.settings` from the loop-invariant scan root (#B376 — the old loop grew the root by one segment per iteration, so every bundle after the first pointed into its predecessors' tree and the protocol/scheme settings-consistency pass silently skipped it), and `addBundlePorts` skips a declared-but-absent bundle's settings check instead of dereferencing its missing `configPaths` (a latent TypeError reachable via a manifest declaring N bundles of which some are swapped for different on-disk names). Tests: `test/lib/project-manifest-resync.test.js` (17 — source pins + union/subtract replicas, red-first validated 11-fail/6-pass against the pre-fix source). **Registration is also protocol/scheme-hygienic (#B378 + #B379 + #B380, fixed in the same release, 0.6.9):** a project's `protocols`/`schemes` lists in `~/.gina/projects.json` are DERIVED bookkeeping constrained to the framework's supported sets in `~/main.json` — they may gain a value a bundle's `settings.json` validly declares (that is how the port matrix learns what a project's bundles use), but an unsupported value is warned about by name (bundle, value, allowed set) once per command and adopted nowhere, not even as the bundle entry's default. Previously ANY string a bundle declared was adopted and persisted, and `image:build` baked the polluted list into the synthesized container's environment (`ENV GINA_PROTOCOLS=…`); worse, the enrichment pass resolved EVERY registered project's bundles against the CURRENT project's path, so two projects sharing a bundle name (an `api`/`web` in both) leaked declarations into each other's registry entries from commands that never named them — each project's bundles now resolve against its own path. The import-time heal that rewrites an invalid bundle declaration to the project default still runs (it keeps the bundle bootable) but reports each change by name as a warning instead of a debug line, and the port-setup merge reads its source list by its own index (it read the target's, pushing `undefined` on overshoot — no demonstrated consequence, fixed for correctness) and admits only framework-supported values, so stale `ports.json` keys from a framework update cannot re-enter the lists. The runtime boot path never reads the project lists (measured — `core/gna.js` zero references, `core/config.js` one comment; `core/server.js`'s read is a dead variable), so pickup is CLI-side only. Tests: `test/lib/project-protocol-hygiene.test.js` (19 — pins + adoption/merge replicas with subtract arms, red-first 12-fail/7-pass against the pre-fix source).
|
|
1057
1057
|
307. **Express engine — Express 5 supported, range declared `>= 4 < 6` (#B211, shipped in 0.6.9).** A bundle with `settings.server.json > engine: "express"` could not boot on Express 5, twice over: (1) the engine-agnostic catch-all mounted `all('*')`, and Express 5's router (path-to-regexp v8) REJECTS the bare-string wildcard at mount time — `TypeError: Missing parameter name`, exit before `listen()`; (2) past the mount, Express 5 defines `req.query` as a prototype GETTER (v4 assigned it from its auto-mounted query middleware as an own property), and the framework's request pipeline ASSIGNS `request.query` at 8 sites under `'use strict'` — each a per-request `TypeError: Cannot set property query ... which has only a getter`. Fixes: the catch-all pattern is ENGINE-CONDITIONAL — express mounts the RegExp `/(.*)/ ` (accepted + live-dispatching `/`, webroot and deep paths on Express 4 AND 5), while isaac KEEPS the string `'*'` because its request listener's dispatch gate is literally `path === '*' || path == request.url` (server.isaac.js) — a RegExp there matches nothing and every request HANGS with no response (measured; the gate is pinned by `test/core/express-engine-compat.test.js` as a tripwire). The express adapter (`core/server.express.js`) shadows `query` on the per-app request prototype with a writable own DATA property — a measured no-op on Express 4, and on 5 it restores the framework's owns-the-query-parse contract. The adapter logs the detected express version at engine construction (`engine: express v<X> (supported: >= 4 < 6)`), and an out-of-range major (`< 4` or `>= 6`) logs a LOUD warning but still boots — warn-not-refuse by design decision (a wrong refusal is a total outage; a wrong warning is a log line). Express stays CONSUMER-PROVIDED: deliberately not a framework dependency, and NEVER a peerDependency — npm >= 7 auto-installs peers, which would force express onto every consumer; install `express@^5` (or `@^4`) in the project itself. Server-side only ⇒ pickup = bundle restart, NO re-bake. Verified by a live A/B boot smoke on express 4.22.2 AND 5.2.1 (same scaffold, both serve `/web/` + `/_gina/health/check` + `/_gina/info` 200 and complete a POST through the query-assign sites) plus 13 unit pins incl. a pure-node replica of the getter mechanism. **#B666 (0.7.0):** the shadow alone left `request.query` UNDEFINED on Express 5 until the pipeline assigned it (its GET/HEAD branches only), and the DELETE branch plus the empty-body fallback of the POST/PUT/PATCH branches count it raw — so ONE DELETE, or one body-less POST/PUT/PATCH, killed the bundle (uncaughtException → SIGTERM; unauthenticated, any URL, before routing; measured live on express 5.2.1 against the released 0.6.33 — Express 4 and isaac never affected). The shadow is now an ACCESSOR on `app.request` (`queryAccessorDescriptor` in `core/server.express.js`): the getter materialises Express's OWN parse — its getter beneath the shadow, so the app's `query parser` setting is honoured at read time — as a PLAIN, writable own property on the first read (`querystring.parse`, the `simple` default on 5, returns a null-prototype object that gina's `Object.prototype.count()` cannot iterate; the copy is built with `defineProperty`, so a client `__proto__` key never reaches the setter), and the setter stores; on Express 4 (no getter on the chain) it reads undefined and stores — the data property's behaviour byte for byte. ⛔ NO `app.use` at engine construction: gina registers nothing before the bundle's `onInitialize`, and creating Express's router early freezes `strict routing` / `case sensitive routing` on both majors and the query parser on Express 4 ahead of a consumer's `app.set(...)` — the first draft did exactly that and was withdrawn on that measurement. Rated High 7.5 (CWE-476, the #B591 class), affected `>= 0.6.9 <= 0.6.33` with Express 5. Tests: `test/core/express-query-b666.test.js` (the shipped helpers extracted and driven on pure-node replicas of both Express request prototypes; red-first 19/24 against the released adapter). **#B668 (0.7.0):** the express engine never stripped the query string from `request.url` (isaac does, in its listener, before dispatch), so on BOTH majors every URL carrying `?…` answered 404 — routes AND statics (`/css/app.css?v=3`) — because `handle()`'s pathname, `handleStatics` and `fitsWithRequirements` all compared the query-carrying URL (measured live on 4.22.3 and 5.2.1 against the released 0.6.33; not an S6 regression). `core/server.js` now strips it for express inside the catch-all, after the `/_gina` handlers (they read `?key=` off the full URL) and before the webroot filter, the statics and the routing loop — materialising `request.query` FIRST, because the accessor reads the URL lazily, and inside that layer so a throwing `query parser` is a 500 through Express's own catch, never a process exit; `request.originalUrl` keeps the full URL. Consequence: the warm route cache's `method:path` keys collapse query variants on express exactly as on isaac (#B422 re-checks the verdict per hit). Tests: `test/core/express-url-query-strip-b668.test.js` (the shipped block extracted and executed, the isaac arm as the inert control; red-first 7/8 against the released server.js).
|
|
1058
1058
|
308. **Boot-time bundle mounts are idempotent, atomic and concurrency-safe (#B381, shipped in 0.6.10).** Several processes may boot ONE shared project tree (e.g. replicas over a POSIX network filesystem) without racing each other's mounts. The primitive is `_.prototype.ensureSymlinkSync(destination[, type])` (helpers/path.js; value=SOURCE, arg=LINK NAME — the same convention as `symlinkSync()`), returning 'kept' | 'created' | 'replaced' | 'concurrent': (1) a link already resolving to the source (relative link text resolved against the link's own dir) whose source exists is KEPT — zero writes in the steady state; (2) otherwise it publishes atomically via a unique temp sibling in the SAME directory + `fs.renameSync()` (rename(2) atomic-replace: no absent-name window, no two-creator collision); (3) a create/rename failure is re-verified and a concurrent IDENTICAL publish counts as success ('concurrent'), anything else re-throws — fail-loud is preserved for a vanished release (source-exists guard) and for a REAL directory occupying the mount path (refused, never silently rm -rf'd — pre-#B381 the config loop silently deleted it). Consumers: `gna.mount` (core/gna.js — which also lost its early unlink whose catch lacked a `return`, a double-callback on a lost race), `isBundleMounted` (no pre-unlink; still forces the mount call when not started so a wrong link gets repaired), and core/config.js's every-bundle loop (previously N processes × M bundles of unserialised unlink/create rewrites per config load — now zero steady-state writes; the #B372 named-bundle abort remains for genuine failures). The mkdir side of the same check-then-create class is closed too: gna.mount's bundles/tmp/cache creation and `lib/generator` `createPathSync` use `fs.mkdirSync(..., {recursive:true})` (the per-segment exists/mkdir walk raced concurrent boots and threw EEXIST uncaught out of the mount). Tests: test/lib/path-ensure-symlink.test.js (real-fs arms incl. the concurrent-tolerance monkeypatch + its genuine-failure control) and test/core/mount-symlink-atomicity.test.js (call-site pins + a TWO-PROCESS contention proof whose pre-fix replica is the firing control — it must collide for the zero-failure arm to certify anything). Server-side only — none of the touched files are browser-bundled, so pickup is a bundle RESTART, no re-bake.
|
|
1059
|
-
309. **Maintenance mode — `settings.json > server.maintenance` (#MAINT1, shipped in 0.6.10).** A bundle can be closed to the public without being stopped: when maintenance is active every request EXCEPT the `/_gina/*` family is answered `503` + `Retry-After` + `Cache-Control: no-store` with a self-contained maintenance page (HTML for a browser navigation, the shipped `{error:{code:'503',message:'GNA:GLOBAL:ERR:503',explicit:<message>}}` body for XHR / `X-Gina-Navigate` / json-only `Accept`). Config: `{enabled:false, retryAfter:300, message, bypassKey, allowFrom:[]}` — `enabled` must be a STRICT boolean, every other key falls back PER KEY with a boot warning, and a malformed block never refuses a boot (the `server.transientErrors` lint contract). **Placement is the feature, and it is why a route middleware cannot substitute:** the gate sits AFTER every `/_gina/*` handler and BEFORE the webroot filter, static-asset serving, both render/output-cache serve points and routing. Middleware only runs once a route has MATCHED, so a middleware-based maintenance mode leaves static assets serving 200 and answers 404 (not 503) for any unmatched URL; a cache serve point above a gate replays cached pages to the public. `/_gina/*` staying reachable is deliberate: `/_gina/health/check` keeps answering 200 so an orchestrator does not restart pods over a declared window (correct k8s maintenance keeps pods READY and serves the 503 from the app — this feature deliberately does NOT flip readiness, which would make the ingress return connection errors instead of your page; a probe that targets an APPLICATION route instead sits below the gate and must present `x-gina-maintenance-key` from the probe's own headers, on EVERY such probe — a failing liveness or startup probe makes the kubelet restart the container — with `bypassKey` in settings before the window opens (config-only; the runtime toggle cannot add it)), and the operator can always reach the toggle. Cost when off is one property read plus a boolean. **Bypass — the key is topology-independent, the IP list is not.** `bypassKey` is compared in constant time (`crypto.timingSafeEqual`, length-guarded, fail-closed when unset) and may be presented as the `x-gina-maintenance-key` header (programmatic callers) or once as `?gina-maintenance-key=…` in a browser, which mints a stateless `<expUnix>.<HMAC-SHA256>` cookie (`gina.maintenance`, 12h, HttpOnly/SameSite=Lax/Path=/, `Secure` on https) and 302s to the same URL WITHOUT the secret — the `Location` is the stripped PATH ONLY, never rebuilt from `Host` or any `X-Forwarded-*` header, since a redirect target built from attacker-controlled headers is an open redirect. Verification recomputes the MAC, so a grant survives a restart and works across every bundle of a merged-mode project sharing the key with nothing to replicate; rotating `bypassKey` revokes every outstanding cookie. `allowFrom` is honoured **ONLY for requests that do NOT classify as proxied** (the `request._ginaIsProxyHost` stamp when present, else the same port-less-Host-OR-`X-Forwarded-Host` heuristic, `server.proxy.requireForwardedHeaders` respected): behind a reverse proxy every `req.socket.remoteAddress` is the PROXY's, so an unconditioned IP allowlist would either admit the entire internet (proxy listed) or nobody (proxy not listed) — the condition turns that silent-open into fail-closed and leaves the key as the bypass that works under any topology. Resolution order: cookie → header → query(+grant) → IP∧¬proxied → 503. An invalid presented key is logged once, never with its value. **Runtime toggle:** `GET/POST /_gina/maintenance` (always-on, admin-gated on `app.json > admin.allowFrom`, both engines, declared ABOVE the gate so the off switch is never stranded behind it). `POST {"enable":true|false[,"ttlSeconds":N,"retryAfter":N,"message":"…"]}`; the status payload reports `hasBypassKey` but NEVER the key. A runtime override is NOT persisted — a restart returns the bundle to its configured state, and an expired `ttlSeconds` reverts to CONFIG rather than to "off", so a forgotten dead-man timer can never re-open a site `settings.json` says is closed. `enabled:true` in config is the durable form. **`GINA_MAINTENANCE=1` (or `true`, any case; 0.6.32) boots a bundle CLOSED without a settings edit** — for a replacement pod created during a window; folded into the CONFIG layer, so the toggle still reopens the process and the status payload reads `source:'env'`; CLOSE-ONLY — `0`/`false`/unset leave the configured state (a rollout-time variable must never be able to open a site the release's configuration closed), any other value warns at boot and is ignored. Honoured under both launchers, on different transports: the daemon path (`bin/cli`) sweeps `GINA_*` into `process.gina`, so under the daemon the variable must be in the environment of the process that STARTED the daemon (a pod's init script; a warm dev daemon passes only its launch-time env), while `bin/gina-container` leaves it in `process.env` and never imports its own env into `process.gina` — the engine reads both (`getEnvVar` first, then `process.env`). **Replica sync (0.6.32) — `server.maintenance.store: '<kv namespace>'` + `pollInterval` (250..60000 ms, default 2000):** every `POST /_gina/maintenance` writes its runtime override to that namespace under the BUNDLE NAME as key (`{v:1, active, until, retryAfter, message, setBy:{pid,hostname}, at}`, store TTL = the remaining `ttlSeconds` window so the record vanishes and every replica reverts to config together; an `enable:false` is WRITTEN, never deleted, so runtime-off wins over a configured `enabled:true` replica-wide), and each process polls it on an unref'd interval whose first tick fires at boot — so one POST anywhere reaches every replica within one interval and a joining replica converges in one round-trip. The gate still reads LOCAL memory synchronously (#B383 holds, #P39 holds: no interval exists without `store`). A poll REJECTION keeps the last-known state (a store outage must never OPEN a closed site) and warns once per outage; a POST answered during the outage applies locally and replies `store: {written: false}`, and that local flip is SUPERSEDED by the next successful poll — the shared record wins, so re-issue the POST once the store answers; a resolved `null` means config; a malformed record is ignored. Boot: a namespace `lib.kv` cannot hand out REFUSES the boot (the `lib/rate-limit` / `lib/idempotency` shape — a deliberate exception to the never-refuse contract, which covers value shape), so does `failMode:'open'` (under it an outage reads as "no record" and would reopen every replica); memory-backed warns "PER PROCESS"; redis without `enableOfflineQueue:false` + `commandTimeout` warns. Status payload gains `sync:{store,key,lastSyncAt,lastError}`; a POST reply carries `store:{written,error}` — a failed write still applies locally and answers 200 with `written:false` (a 5xx would invite retrying a POST that already applied). The namespace is a CONTROL-PLANE surface (whoever can write it can close or reopen the deployment) — keep it private, the same trust boundary as sessions/rate-limit/idempotency in a shared store. **`ttlSeconds` is an INTEGER in 1..86400 (24h) and a PRESENT value outside that is refused 400 naming the bound and the config alternative (#B498, 0.6.28) — until then any invalid ttl (25h, a float, a numeric string) was SILENTLY ignored and the flip applied with NO timer, i.e. an unbounded window when a bounded one was asked for, `until: null` the only tell; absent or `null` still mean "no timer". Refused rather than clamped on purpose: a shorter-than-asked maintenance window reopens a site mid-deploy, so there is no safe direction to round toward (the instrument window clamps because a shorter CAPTURE window is harmless).** Scope is per BUNDLE: state lives on the engine instance and a server serves exactly one bundle, so merged-process mode gets per-bundle isolation for free. **And per PROCESS: the runtime override lives in the memory of the process that took the `POST` — never written or broadcast — so with replicas a `POST` closes ONE replica and a replica that restarts mid-window returns to config; the status payload carries `pid` + `hostname` (the pod name under k8s) so an operator fanning the `POST` out can read back which processes applied it. Coherence across a deployment is the consumer's to build — fan-out, or `enabled:true` in config rolled out to every replica; the bypass cookie is the exception, stateless and keyed on `bypassKey`, accepted by every replica sharing it.** **Verified scope of the "every request except `/_gina/*`" claim:** the gate is pinned on the engine-agnostic `onRequest()` path (express) and the isaac request listener, above statics, both render/output-cache serve points and routing. **The contract holds under HTTP/2, MEASURED (#B383, resolved 2026-08-16) — and the reason is a CONSTRAINT that binds every future pre-routing gate: the gate must be SYNCHRONOUS.** Node's compat layer registers its own `'stream'` listener when a `'request'` listener attaches, so `core/server.js`'s separate raw byte-serving `onHttp2Strem` — registered lazily from inside the first h2 static request, and therefore SECOND on the same emitter — runs only after the compat listener returns. Because EventEmitter dispatches synchronously in registration order, a synchronous gate completes its 503 while the compat listener still holds the stack. An ASYNC gate (`await`, callback, promise, `setImmediate`) yields to the emitter first and the raw listener then serves the asset underneath it: measured as a 200 BYPASS in a 6-arm micro-replica whose synchronous arm held at 503, with a positive control serving 200. Live h2 boot confirmed statics/routed/unmatched all 503 while `/_gina/health/check` stayed 200, plus one harmless `ERR_HTTP2_HEADERS_SENT` (gina's listener losing the race it was always going to lose) which `lib/proc` downgrades to a warn — the bundle stays up. ws UPGRADEs are deliberately out of scope in slice 1 (existing sockets survive a window). Server-side only — `lib/maintenance` is absent from the browser `build.json`, so pickup is a bundle RESTART, no re-bake.
|
|
1059
|
+
309. **Maintenance mode — `settings.json > server.maintenance` (#MAINT1, shipped in 0.6.10).** A bundle can be closed to the public without being stopped: when maintenance is active every request EXCEPT the `/_gina/*` endpoints is answered `503` + `Retry-After` + `Cache-Control: no-store` with a self-contained maintenance page (HTML for a browser navigation, the shipped `{error:{code:'503',message:'GNA:GLOBAL:ERR:503',explicit:<message>}}` body for XHR / `X-Gina-Navigate` / json-only `Accept`). Config: `{enabled:false, retryAfter:300, message, bypassKey, allowFrom:[]}` — `enabled` must be a STRICT boolean, every other key falls back PER KEY with a boot warning, and a malformed block never refuses a boot (the `server.transientErrors` lint contract). **Placement is the feature, and it is why a route middleware cannot substitute:** the gate sits AFTER every `/_gina/*` handler and BEFORE the webroot filter, static-asset serving, both render/output-cache serve points and routing. Middleware only runs once a route has MATCHED, so a middleware-based maintenance mode leaves static assets serving 200 and answers 404 (not 503) for any unmatched URL; a cache serve point above a gate replays cached pages to the public. `/_gina/*` staying reachable is deliberate: `/_gina/health/check` keeps answering 200 so an orchestrator does not restart pods over a declared window (correct k8s maintenance keeps pods READY and serves the 503 from the app — this feature deliberately does NOT flip readiness, which would make the ingress return connection errors instead of your page; a probe that targets an APPLICATION route instead sits below the gate and must present `x-gina-maintenance-key` from the probe's own headers, on EVERY such probe — a failing liveness or startup probe makes the kubelet restart the container — with `bypassKey` in settings before the window opens (config-only; the runtime toggle cannot add it)), and the operator can always reach the toggle. Cost when off is one property read plus a boolean. **Bypass — the key is topology-independent, the IP list is not.** `bypassKey` is compared in constant time (`crypto.timingSafeEqual`, length-guarded, fail-closed when unset) and may be presented as the `x-gina-maintenance-key` header (programmatic callers) or once as `?gina-maintenance-key=…` in a browser, which mints a stateless `<expUnix>.<HMAC-SHA256>` cookie (`gina.maintenance`, 12h, HttpOnly/SameSite=Lax/Path=/, `Secure` on https) and 302s to the same URL WITHOUT the secret — the `Location` is the stripped PATH ONLY, never rebuilt from `Host` or any `X-Forwarded-*` header, since a redirect target built from attacker-controlled headers is an open redirect. Verification recomputes the MAC, so a grant survives a restart and works across every bundle of a merged-mode project sharing the key with nothing to replicate; rotating `bypassKey` revokes every outstanding cookie. `allowFrom` is honoured **ONLY for requests that do NOT classify as proxied** (proxied = a `true` `request._ginaIsProxyHost` stamp — a `false` one never vetoes, it derives from the same client-supplied `Host` — OR any `X-Forwarded-*` / `Forwarded` header OR a port-less `Host`, the last unless `server.proxy.requireForwardedHeaders`): behind a reverse proxy every `req.socket.remoteAddress` is the PROXY's, so an unconditioned IP allowlist would either admit the entire internet (proxy listed) or nobody (proxy not listed) — the condition turns that silent-open into fail-closed and leaves the key as the bypass that works under any topology. Since #B711 (0.7.1) a listed direct client also passes on an isaac bundle whose `server.protocol` is http/1.1: isaac's gate decides on the pristine `Host` and marks the request (`request._ginaMaintenanceAdmitted`), and the second gate in `core/server.js` honours the mark — before, it decided again after isaac had rewritten the `Host` without its port (isaac does so only under that protocol, so a bundle serving HTTP/2 was never affected, whatever protocol its clients speak), read the client as proxied and answered 503 on every page and static file (0.6.10–0.7.0). Resolution order: cookie → header → query(+grant) → IP∧¬proxied → 503. An invalid presented key is logged once, never with its value. **Runtime toggle:** `GET/POST /_gina/maintenance` (always-on, admin-gated on `app.json > admin.allowFrom`, both engines, declared ABOVE the gate so the off switch is never stranded behind it). Since #B714 (0.7.1) a JSON-bodied `POST` no longer stops an express bundle: the body reader threw on the text chunks that core/server.js's `request.setEncoding` delivers. Since #B709 (0.7.1) the toggle admits a loopback caller only when it connects DIRECTLY — a request relayed by a proxy on the bundle's own host is refused — and matches only its exact path, the root form or the bundle's own webroot form with any query ignored, so a nested path (`/_gina/health/check/_gina/maintenance`), the path in a query string, another letter case or `//_gina/…` no longer reaches it: call it on the bundle's port from the host or the pod, and give a same-host health-probe proxy an EXACT `location = /_gina/health/check`, never a prefix location. `POST {"enable":true|false[,"ttlSeconds":N,"retryAfter":N,"message":"…"]}`; the status payload reports `hasBypassKey` but NEVER the key. A runtime override is NOT persisted — a restart returns the bundle to its configured state, and an expired `ttlSeconds` reverts to CONFIG rather than to "off", so a forgotten dead-man timer can never re-open a site `settings.json` says is closed. `enabled:true` in config is the durable form. **`GINA_MAINTENANCE=1` (or `true`, any case; 0.6.32) boots a bundle CLOSED without a settings edit** — for a replacement pod created during a window; folded into the CONFIG layer, so the toggle still reopens the process and the status payload reads `source:'env'`; CLOSE-ONLY — `0`/`false`/unset leave the configured state (a rollout-time variable must never be able to open a site the release's configuration closed), any other value warns at boot and is ignored. Honoured under both launchers, on different transports: the daemon path (`bin/cli`) sweeps `GINA_*` into `process.gina`, so under the daemon the variable must be in the environment of the process that STARTED the daemon (a pod's init script; a warm dev daemon passes only its launch-time env), while `bin/gina-container` leaves it in `process.env` and never imports its own env into `process.gina` — the engine reads both (`getEnvVar` first, then `process.env`). **Replica sync (0.6.32) — `server.maintenance.store: '<kv namespace>'` + `pollInterval` (250..60000 ms, default 2000):** every `POST /_gina/maintenance` writes its runtime override to that namespace under the BUNDLE NAME as key (`{v:1, active, until, retryAfter, message, setBy:{pid,hostname}, at}`, store TTL = the remaining `ttlSeconds` window so the record vanishes and every replica reverts to config together; an `enable:false` is WRITTEN, never deleted, so runtime-off wins over a configured `enabled:true` replica-wide), and each process polls it on an unref'd interval whose first tick fires at boot — so one POST anywhere reaches every replica within one interval and a joining replica converges in one round-trip. The gate still reads LOCAL memory synchronously (#B383 holds, #P39 holds: no interval exists without `store`). A poll REJECTION keeps the last-known state (a store outage must never OPEN a closed site) and warns once per outage; a POST answered during the outage applies locally and replies `store: {written: false}`, and that local flip is SUPERSEDED by the next successful poll — the shared record wins, so re-issue the POST once the store answers; a resolved `null` means config; a malformed record is ignored. Boot: a namespace `lib.kv` cannot hand out REFUSES the boot (the `lib/rate-limit` / `lib/idempotency` shape — a deliberate exception to the never-refuse contract, which covers value shape), so does `failMode:'open'` (under it an outage reads as "no record" and would reopen every replica); memory-backed warns "PER PROCESS"; redis without `enableOfflineQueue:false` + `commandTimeout` warns. Status payload gains `sync:{store,key,lastSyncAt,lastError}`; a POST reply carries `store:{written,error}` — a failed write still applies locally and answers 200 with `written:false` (a 5xx would invite retrying a POST that already applied). The namespace is a CONTROL-PLANE surface (whoever can write it can close or reopen the deployment) — keep it private, the same trust boundary as sessions/rate-limit/idempotency in a shared store. **`ttlSeconds` is an INTEGER in 1..86400 (24h) and a PRESENT value outside that is refused 400 naming the bound and the config alternative (#B498, 0.6.28) — until then any invalid ttl (25h, a float, a numeric string) was SILENTLY ignored and the flip applied with NO timer, i.e. an unbounded window when a bounded one was asked for, `until: null` the only tell; absent or `null` still mean "no timer". Refused rather than clamped on purpose: a shorter-than-asked maintenance window reopens a site mid-deploy, so there is no safe direction to round toward (the instrument window clamps because a shorter CAPTURE window is harmless).** Scope is per BUNDLE: state lives on the engine instance and a server serves exactly one bundle, so merged-process mode gets per-bundle isolation for free. **And per PROCESS: the runtime override lives in the memory of the process that took the `POST` — never written or broadcast — so with replicas a `POST` closes ONE replica and a replica that restarts mid-window returns to config; the status payload carries `pid` + `hostname` (the pod name under k8s) so an operator fanning the `POST` out can read back which processes applied it. Coherence across a deployment is the consumer's to build — fan-out, or `enabled:true` in config rolled out to every replica; the bypass cookie is the exception, stateless and keyed on `bypassKey`, accepted by every replica sharing it.** **Verified scope of the "every request except the `/_gina/*` endpoints" claim:** the gate is pinned on the engine-agnostic `onRequest()` path (express) and the isaac request listener, above statics, both render/output-cache serve points and routing. Neither gate tests the path: an endpoint passes because its handler sits above the gate, so a `/_gina/` url no handler answers gets the 503 on both engines. Since #B715 (0.7.1) the isaac gate also lets through the `/_gina/*` paths that only the engine-agnostic layer answers — `/_gina/storage/stats`, `/gc`, `/verify`, and `/_gina/health/check` with a query string, which isaac's `$`-anchored handler missed on the raw url until #B717 — matched on the exact query-free root path (`_isLeftToServerJs`, kept in step with core/server.js's /_gina band by a parity test); before, they answered isaac's 503 for the whole window (0.6.10–0.7.0), so `gina storage:*` failed against a running isaac bundle. Since #B717 (0.7.1) `/_gina/health/check`, `/_gina/assets/routing.json`, `/_gina/release/status` and `/_gina/release/events` match `(?:\?|$)` on both engines — a query string missed them (isaac tests the raw url above its strip; express's /_gina band sits above the #B668 strip): a 404 on express, isaac's 503 in a window for the routing map and the release endpoints; isaac's routing-map fast path looks its asset up by the name its test matched (the query-free path's last segment would name `x` for `/x?y=/_gina/assets/routing.json`, which the unanchored test matches, and `localAsset.mime` would throw). Since #B718 (0.7.1) the health check answers `HEAD` on both engines — the GET status and headers plus `content-length`, no body — where HEAD fell through to routing: a 404, or the 503 in a window. The dev-only `/_gina/inspector`, `/logs`, `/indexes`, `/reveal` still miss a query string (LOW residual). **The contract holds under HTTP/2, MEASURED (#B383, resolved 2026-08-16) — and the reason is a CONSTRAINT that binds every future pre-routing gate: the gate must be SYNCHRONOUS.** Node's compat layer registers its own `'stream'` listener when a `'request'` listener attaches, so `core/server.js`'s separate raw byte-serving `onHttp2Strem` — registered lazily from inside the first h2 static request, and therefore SECOND on the same emitter — runs only after the compat listener returns. Because EventEmitter dispatches synchronously in registration order, a synchronous gate completes its 503 while the compat listener still holds the stack. An ASYNC gate (`await`, callback, promise, `setImmediate`) yields to the emitter first and the raw listener then serves the asset underneath it: measured as a 200 BYPASS in a 6-arm micro-replica whose synchronous arm held at 503, with a positive control serving 200. Live h2 boot confirmed statics/routed/unmatched all 503 while `/_gina/health/check` stayed 200, plus one harmless `ERR_HTTP2_HEADERS_SENT` (gina's listener losing the race it was always going to lose) which `lib/proc` downgrades to a warn — the bundle stays up. ws UPGRADEs are deliberately out of scope in slice 1 (existing sockets survive a window). Server-side only — `lib/maintenance` is absent from the browser `build.json`, so pickup is a bundle RESTART, no re-bake.
|
|
1060
1060
|
|
|
1061
1061
|
310. **`Collection.replace()` resolves its comparison key from BOTH sides — the stored entry AND the caller's `set` (#B393, gh issue #64, shipped in 0.6.11).** `replace(filter, set[, key])` locates each entry to overwrite by comparing a key on the stored row against the same key on `set`. That key used to be chosen by inspecting the STORED row alone: it defaulted to the internal `_uuid`, fell back to `id` only when the stored row had no `_uuid`, and threw `No comparison key defined !` only in that same stored-row-lacks-it case. So the one combination neither branch covered — **stored row HAS `_uuid`, `set` does NOT** — compared `<storedUuid> == undefined`, which is never true: nothing matched, nothing was replaced, no error was raised, and the call returned a chainable result that looked successful. A silent, lossy write. Reachability is the non-obvious part: a Collection built from fresh raw data keeps no `_uuid` on the instance rows, so the `id` fallback fires and the call works — the defect appears only once the caller has **re-loaded an array a previous chained call returned**, because chained results carry the internal `_uuid` on every row the call did not replace (and `toRaw()` KEEPS a `_uuid` the caller supplied, since the constructor stamps `_hasItsOwnUuid` on reload). That is why it presents as intermittent rather than constant, and why persisting a chained result is the trigger. Now: the key is resolved per entry from both sides, falling back to `id` when BOTH carry one, and refusing loudly when the two sides share no usable key — so the previously-silent combination is no longer the quiet one. An explicitly supplied `key` argument is honoured exactly as before (no fallback, no refusal). The resolved key is also scoped per entry instead of assigned to the shared variable, so a fallback taken for one row can no longer apply to rows examined after it. Every previously-working shape is unchanged, including `set` carrying a matching `_uuid` and the fresh-collection `id` path. **`lib/collection` IS in the browser `build.json`, so pickup is a bundle RESTART **and** a re-bake.**
|
|
1062
1062
|
|