diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 27ffc44e07..8e401dc9be 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -4,6 +4,28 @@ Web Console preserves compatible API paths, schema and resource names, action na Visible branding, product-owned assets, icon identifiers, package metadata, and operator documentation use PastureStack. Historical identifiers remain only where they are server data or protocol contracts and must not be mechanically replaced. +Candidate `1.6.171` confines create-response adoption to ID-less POST/201 and an +existing exact-ID/concrete-type canonical model in the same Store, generation +and API base. It does not re-import stale scalar or nested create fields over +that model. The original draft-save completion identity and subtype/base aliases +remain intact. Resource IDs are not normalized. Missing schemas grant no access. +GET, PUT, action POST (including reused options), uncached creates, non-201, +204 and error paths retain normal processing. No API authorization, session, +MFA, payload, resource lifecycle or backend changes are introduced. +Revision 5 is a new archive; revision 4 is not overwritten. Focused Chrome +validation passed 36/36, including ten new cases and 100 barrier iterations; +failure, skip and todo counts are zero. Official validation, publication and +packaged fresh-volume acceptance remain pending, not full-matrix PASS. +See the [release note](docs/releases/web-console-1.6.171.md). + +The live npm audit retains its Critical/High threshold. An explicit dated +vendor-pending record covers only `GHSA-vfj7-8cjw-p6xm` in the exact existing +development-only `braces@3.0.3` dependency closure, for which upstream has no +patched release. Unknown findings, changed affected nodes, runtime exposure, +audit errors and expired reviews fail closed. This is a recorded remaining High +risk, not a patched or zero-High claim; dependencies and package versions are +unchanged. See [the review record](docs/security/npm-vendor-pending.json). + Published `1.6.170` accepts null only for the optional expanded `mounts` projection while retaining the real complete empty pool relationship and full scoped mount-cache proof. It preserves nonempty raw ID binding, current-project diff --git a/README.md b/README.md index e55a6f115f..6a6aa2a6c1 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,29 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status +Candidate `1.6.171` repairs a shared Store ordering defect: a delayed initial +create response could overwrite a newer subscribe model and leave a successfully +created local Volume stuck in its initial state. Only ID-less create POST/201 +uses an existing exact-ID, concrete-type canonical model in the same Store, +generation and API base. Ordinary reads, updates, actions and backend permissions +keep their existing contracts. API-store compatibility revision 5 replaces +revision 4 without changing the dependency graph; earlier archives are retained. +Focused Chrome validation passed 36/36 tests, including ten new regressions and +100 deterministic subscribe-before-201 barrier iterations, with no failures, +skips or todo. Official validation, immutable publication and packaged fresh-volume +acceptance are separate pending gates. The complete permission / +resource / locale matrix remains INCOMPLETE. See the +[release note](docs/releases/web-console-1.6.171.md). + +The first exact-source official run stopped before tests on newly reviewed +`GHSA-vfj7-8cjw-p6xm` in build-only `braces@3.0.3`; upstream has no patched +release. It remains a High vendor-pending finding, not a zero-vulnerability +claim. The live audit preserves the High threshold and rejects unexpected +advisories, dependency drift, non-development exposure and expired reviews. +Only the exact reviewed advisory's dependency closure may remain pending until +2026-10-10. No third-party runtime patch or toolchain downgrade is applied. +See the [bounded risk record](docs/security/npm-vendor-pending.json). + Published `1.6.170` corrects an optional `mounts: null` projection being mistaken for a real allocation in the shared local-volume list. It preserves the complete advertised pool relationship, full scoped mount cache, exact-volume diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index 3f22097502..c5d74e292d 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.170", + "version": "1.6.171", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.170", + "version": "1.6.171", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" @@ -33,7 +33,7 @@ "core-js": "file:vendor/core-js-compat/core-js-2.6.13-rc16.0.tgz", "d3": "7.9.0", "dagre-d3-es": "7.0.14", - "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz", + "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", "ember-auto-import": "2.13.1", "ember-basic-dropdown": "9.0.0", "ember-cli": "7.2.0", @@ -9051,8 +9051,8 @@ }, "node_modules/ember-api-store": { "version": "2.8.5", - "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz", - "integrity": "sha512-Z/ZLyAm2ne25B17gONI/s/ufRRz1uH4CfOZ3VbUItBwXnSpW+ckZKub+2vC82fr9YOtgrgIsqirBMf3yfWo2Zw==", + "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", + "integrity": "sha512-m+IpOrSUqogl3EP8DqefpDuO/9leZ4Bycge7MLwqYASOz75V/J6ay1bFGaOWd2ckaohymODeOlkVzyVzmWLupw==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/docs/releases/web-console-1.6.171.md b/docs/releases/web-console-1.6.171.md new file mode 100644 index 0000000000..ca3995d78c --- /dev/null +++ b/docs/releases/web-console-1.6.171.md @@ -0,0 +1,70 @@ +# Web Console 1.6.171 + +Candidate shared Store fix; publication and packaged QA remain separate gates. + +## Root cause and changes + +In packaged native QA, subscribe delivered an inactive created local Volume +before the browser received its original HTTP 201 response. Importing that +initial response replaced the newer canonical model with registering fields. +The backend creation succeeded, but the frontend never reached the expected +stable model state. This is not fixed by relaxing allocation or loading checks. + +`vendor/ember-api-store-compat/addon/mixins/type.js` marks only an ID-less new +record's POST with its concrete type, Store generation and API base. Existing +record saves and actions discard a reused marker. The marker is internal request +metadata, not JSON payload. The existing save merge and canonical alias logic +preserve the saved draft's identity. + +`vendor/ember-api-store-compat/addon/services/store.js` uses a canonical model +already present for the exact opaque ID and concrete type only for a matching +create POST/201 in that same Store/generation/API base. It does not typeify the +old response's fields or nested resources over that model. HTTP status and xhr +metadata retain their contracts. Uncached create, GET, PUT, action, non-201, +204 and errors retain the existing path. This is not a general timestamp-based +ordering rule for all updates. + +Compatibility revision 5 uses a new immutable archive; revision 4 is unchanged. +The lockfile's dependency versions/graph remain unchanged. No authentication, +backend, authorization, data migration or production configuration changes. + +## Verification boundary + +Ten regression tests use the installed Store/Resource/Schema/Collection package, +not an alternate handwritten store. A deferred HTTP barrier repeats the +subscribe-before-201 race 100 times without sleeps. Adjacent cases cover +uncached creation, subtype/base aliases, stale nested fields, case-sensitive +IDs, distinct stores, reset generation, changed base, ordinary methods, +204/errors, existing-save option reuse and action option reuse. + +Focused local Chrome 153 validation passed 36/36 tests with zero failure, skip +or todo, including all ten new cases and 100 deferred-barrier iterations. +Adjacent Store/schema/reference, allocation-proof, route and subscribe-session +cases remain passing. The installed revision-5 archive matches the runtime source. +Exact-source official validation, signed numeric release and packaged native +fresh-volume create/cancel/refresh/denial/removal remain pending. +Historical failed QA receipts stay HOLD; the complete +permission/resource/locale matrix remains INCOMPLETE. + +## Upstream-pending build dependency + +Official run 37092519936 stopped before QUnit on the newly reviewed +[braces stack-exhaustion advisory](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm). +The registry's latest version remains 3.0.3 and the advisory lists no patched +release. Existing build-tool consumers remain unchanged. Do not apply the npm +suggested forced Ember CLI downgrade or privately patch third-party code. + +The [dated risk record](../security/npm-vendor-pending.json) keeps this High +finding visible. The live audit remains fail-closed at High for any other or +changed advisory, changed affected dependency nodes, non-development exposure, +expired review or audit failure. Only this exact reviewed build-only closure +may remain vendor-pending until 2026-10-10. That exception is not a claim that +the vulnerable package is patched or that the source graph has zero High +findings. The packaged static artifact must exclude the affected Node package. + +## Upgrade and rollback + +Use the separately released Server patch that packages this exact component. +Retain existing Compose environment, persistent volumes and the previous +immutable image. No database migration or runtime patch is required. This work +does not authorize company deployment or a change to HAProxy/OIDC settings. diff --git a/docs/security/npm-vendor-pending.json b/docs/security/npm-vendor-pending.json new file mode 100644 index 0000000000..bad4438d16 --- /dev/null +++ b/docs/security/npm-vendor-pending.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": 1, + "advisoryUrl": "https://github.com/advisories/GHSA-vfj7-8cjw-p6xm", + "cve": "CVE-2026-93687", + "severity": "high", + "upstreamPatchedVersion": null, + "reviewedAt": "2026-10-03T03:22:25Z", + "reviewUntil": "2026-10-10", + "scope": "controlled-dev-build-inputs-only", + "risk": "Deeply nested brace patterns can exhaust the build Node.js stack. This High finding remains unresolved; reviewed source filenames/glob configuration are controlled build inputs, not browser/user-supplied patterns.", + "publicationBlockedIfShippedNodes": true, + "shippedNodes": [], + "boundaryEvidence": [ + "The exact reverse lock dependency closure below is dev:true and is not reachable from package-lock root production dependencies.", + "The closure enters through ember-cli, a build CLI. The gate checks literal imports in app/config/vendor JavaScript and ember-cli-build.js; only the exact build entry require('ember-cli/lib/broccoli/ember-app') may reach this closure. This static check is not packaged-browser proof.", + "CI builds an immutable checkout with npm ci --ignore-scripts; this decision does not authorize running a build on untrusted patterns or shipping these nodes.", + "This is a source inventory/build-input review, not a zero-CVE statement or runtime not-affected VEX. If a packaged browser/module inventory includes any reviewed node, publication is blocked and this decision must be re-reviewed." + ], + "nodes": [ + { "path": "node_modules/braces", "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true }, + { "path": "node_modules/micromatch", "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true }, + { "path": "node_modules/findup-sync", "version": "5.0.0", "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-5.0.0.tgz", "integrity": "sha512-MzwXju70AuyflbgeOhzvQWAvvQdo1XL0A9bVvlXsYcFEBM87WR4OakL4OfZq+QRmr+duJubio+UtNQCPsVESzQ==", "dev": true }, + { "path": "node_modules/find-yarn-workspace-root", "version": "2.0.0", "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", "dev": true }, + { "path": "node_modules/sane", "version": "5.0.1", "resolved": "https://registry.npmjs.org/sane/-/sane-5.0.1.tgz", "integrity": "sha512-9/0CYoRz0MKKf04OMCO3Qk3RQl1PAwWAhPSQSym4ULiLpTZnrY1JoZU0IEikHu8kdk2HvKT/VwQMq/xFZ8kh1Q==", "dev": true }, + { "path": "node_modules/broccoli", "version": "4.0.0", "resolved": "https://registry.npmjs.org/broccoli/-/broccoli-4.0.0.tgz", "integrity": "sha512-p5el5/ig0QeRGFPkLMPdm7KblkTm44eicEWfwnRTz6hncghVuRZ0+XDAtCi7ynxobeE/mey5Q7lAulFkgNzxVA==", "dev": true }, + { "path": "node_modules/ember-cli", "version": "7.2.0", "resolved": "https://registry.npmjs.org/ember-cli/-/ember-cli-7.2.0.tgz", "integrity": "sha512-EafquLJ+EVHz0nNo32NWwAfHr5UxTXn9zdlukuKZFSFrR4G6RRStmBPQ5O0bLSaQVDtU+jOe5gGTHSS4Xy3uQA==", "dev": true } + ], + "edges": [ + { "from": "node_modules/micromatch", "to": "node_modules/braces", "spec": "^3.0.3" }, + { "from": "node_modules/findup-sync", "to": "node_modules/micromatch", "spec": "^4.0.4" }, + { "from": "node_modules/find-yarn-workspace-root", "to": "node_modules/micromatch", "spec": "^4.0.2" }, + { "from": "node_modules/sane", "to": "node_modules/micromatch", "spec": "^4.0.2" }, + { "from": "node_modules/broccoli", "to": "node_modules/findup-sync", "spec": "^5.0.0" }, + { "from": "node_modules/broccoli", "to": "node_modules/sane", "spec": "^5.0.1" }, + { "from": "node_modules/ember-cli", "to": "node_modules/broccoli", "spec": "^4.0.0" }, + { "from": "node_modules/ember-cli", "to": "node_modules/find-yarn-workspace-root", "spec": "^2.0.0" }, + { "from": "node_modules/ember-cli", "to": "node_modules/sane", "spec": "^5.0.1" } + ] +} diff --git a/package-lock.json b/package-lock.json index 3f22097502..c5d74e292d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.170", + "version": "1.6.171", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.170", + "version": "1.6.171", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" @@ -33,7 +33,7 @@ "core-js": "file:vendor/core-js-compat/core-js-2.6.13-rc16.0.tgz", "d3": "7.9.0", "dagre-d3-es": "7.0.14", - "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz", + "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", "ember-auto-import": "2.13.1", "ember-basic-dropdown": "9.0.0", "ember-cli": "7.2.0", @@ -9051,8 +9051,8 @@ }, "node_modules/ember-api-store": { "version": "2.8.5", - "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz", - "integrity": "sha512-Z/ZLyAm2ne25B17gONI/s/ufRRz1uH4CfOZ3VbUItBwXnSpW+ckZKub+2vC82fr9YOtgrgIsqirBMf3yfWo2Zw==", + "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", + "integrity": "sha512-m+IpOrSUqogl3EP8DqefpDuO/9leZ4Bycge7MLwqYASOz75V/J6ay1bFGaOWd2ckaohymODeOlkVzyVzmWLupw==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 1902dab79e..28758ebccb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.170", + "version": "1.6.171", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { @@ -76,7 +76,7 @@ "core-js": "file:vendor/core-js-compat/core-js-2.6.13-rc16.0.tgz", "d3": "7.9.0", "dagre-d3-es": "7.0.14", - "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz", + "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", "ember-auto-import": "2.13.1", "ember-basic-dropdown": "9.0.0", "ember-cli": "7.2.0", diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index 5c58ee80b0..d78bc35a33 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.170" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.170" +if [[ "$version" != "1.6.171" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.171" failures=$((failures + 1)) fi diff --git a/scripts/check-node24-lock-baseline b/scripts/check-node24-lock-baseline index b9017c1f12..819895bdb0 100755 --- a/scripts/check-node24-lock-baseline +++ b/scripts/check-node24-lock-baseline @@ -32,6 +32,8 @@ docker run --rm \ cp package.json "$tmpdir/package.json" cp package-lock.json "$tmpdir/package-lock.json" cp scripts/node24-lock-smoke.js "$tmpdir/node24-lock-smoke.js" + mkdir -p "$tmpdir/tests/unit/vendor" + cp tests/unit/vendor/api-store-create-order-test.js "$tmpdir/tests/unit/vendor/api-store-create-order-test.js" mkdir -p "$tmpdir/public/licenses" cp public/licenses/qrcode-generator-MIT.txt "$tmpdir/public/licenses/qrcode-generator-MIT.txt" mkdir -p "$tmpdir/vendor" diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index 3d3469751a..60fc4ade01 100755 --- a/scripts/check-ui-console-workspace +++ b/scripts/check-ui-console-workspace @@ -141,4 +141,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI fi printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \ - 1.6.170 browser-session broker-broadcast + 1.6.171 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 894541c21c..51ff79c629 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -51,9 +51,13 @@ if failures: package = json.loads(package_path.read_text(encoding="utf-8")) ci_source = ci_path.read_text(encoding="utf-8") -if "npm audit --audit-level=high" not in ci_source: - fail("live npm Critical/High audit gate is missing from scripts/ci") -api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz" +for gate in ("node ./scripts/test-ui-npm-audit.js", "node ./scripts/check-ui-npm-audit.js"): + if gate not in ci_source: + fail(f"live fail-closed Critical/High audit gate is missing: {gate}") +for evidence in ("scripts/check-ui-npm-audit.js", "scripts/test-ui-npm-audit.js", "docs/security/npm-vendor-pending.json"): + if not Path(evidence).is_file(): + fail(f"reviewed live audit evidence is missing: {evidence}") +api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz" lock_bytes = lock_path.read_bytes() baseline_bytes = baseline_path.read_bytes() if lock_bytes != baseline_bytes: @@ -66,7 +70,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.170": +if package.get("version") != "1.6.171": fail(f"unexpected Web Console version: {package.get('version')}") if root.get("version") != package.get("version"): fail(f"lock root version differs: {root.get('version')}") @@ -169,7 +173,7 @@ if failures: print( "UI_CRITICAL_HIGH_DEPENDENCIES_OK " - "critical_babel_traverse=absent high_build_chain=patched " + "critical_babel_traverse=absent security_pins=retained " + " ".join(f"{name}={count}" for name, count in sorted(checked.items())) ) print("failure_count=0") diff --git a/scripts/check-ui-ember-api-store-fetch-upgrade b/scripts/check-ui-ember-api-store-fetch-upgrade index 8dfd0b6cfc..de21614f27 100755 --- a/scripts/check-ui-ember-api-store-fetch-upgrade +++ b/scripts/check-ui-ember-api-store-fetch-upgrade @@ -13,7 +13,7 @@ package = json.loads(package_path.read_text(encoding="utf-8")) lock = json.loads(lock_path.read_text(encoding="utf-8")) packages = lock.get("packages", {}) compat_spec = "file:vendor/ember-fetch-compat/ember-fetch-5.1.3-pasturestack.6.tgz" -api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.4.tgz" +api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz" def fail(message): @@ -110,24 +110,24 @@ with tarfile.open(archive_path, "r:gz") as archive: fail("ember-fetch compatibility source and install archive differ") api_store_compat_dir = repo / "vendor/ember-api-store-compat" -api_store_archive = api_store_compat_dir / "ember-api-store-2.8.5-pasturestack.4.tgz" +api_store_archive = api_store_compat_dir / "ember-api-store-2.8.5-pasturestack.5.tgz" api_store_package_path = api_store_compat_dir / "package.json" api_store_license_path = api_store_compat_dir / "LICENSE" api_store_upstream_path = api_store_compat_dir / "UPSTREAM.md" for required in [api_store_archive, api_store_package_path, api_store_license_path, api_store_upstream_path]: if not required.is_file(): fail(f"reviewed ember-api-store compatibility file missing: {required.relative_to(repo)}") -if hashlib.sha256(api_store_archive.read_bytes()).hexdigest() != "32120c02f8e8f8fbe98ad643420c2eb5d48382e674e7561631dd5015c28c6fec": +if hashlib.sha256(api_store_archive.read_bytes()).hexdigest() != "90da9ebdc36a8069629086d011e799691ace8f88c13d9c9df1333c77015a2ab8": fail("reviewed ember-api-store compatibility archive hash changed") if hashlib.sha256(api_store_license_path.read_bytes()).hexdigest() != "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594": fail("ember-api-store upstream Apache-2.0 license changed") api_store_package = json.loads(api_store_package_path.read_text(encoding="utf-8")) if api_store_package.get("dependencies") != expected_api_store_deps: fail("ember-api-store compatibility source metadata changed") -if api_store_package.get("pasturestackCompatibility", {}).get("revision") != 4: +if api_store_package.get("pasturestackCompatibility", {}).get("revision") != 5: fail("ember-api-store compatibility revision marker is missing") with tarfile.open(api_store_archive, "r:gz") as archive: - for relative in ["package.json", "LICENSE", "UPSTREAM.md", "addon/services/store.js"]: + for relative in ["package.json", "LICENSE", "UPSTREAM.md", "addon/services/store.js", "addon/mixins/type.js"]: archived = archive.extractfile(f"package/{relative}") source = api_store_compat_dir / relative if archived is None or archived.read() != source.read_bytes(): @@ -148,6 +148,34 @@ for marker in ["actual bulk cache and inherited Resource.schema", "mixed-case ca if marker not in schema_lookup_tests: fail(f"API-store schema lookup regression missing: {marker}") +type_runtime = (api_store_compat_dir / "addon/mixins/type.js").read_text(encoding="utf-8") +create_save = type_runtime.split(" save: function(opt) {", 1)[1] +action_dispatch = type_runtime.split(" doAction: function(name, data, opt) {", 1)[1].split(" save: function(opt) {", 1)[0] +if "delete opt.createIdentity;" not in action_dispatch: + fail("action POST must clear any reused create identity") +request_success = api_store_runtime.split(" _requestSuccess(xhr,opt) {", 1)[1].split(" _requestFailed(xhr,opt) {", 1)[0] +for marker in ["delete opt.createIdentity;", "if ( opt.method === 'POST' )", "opt.createIdentity = {", "generation: get(store, 'generation')", "baseUrl: get(store, 'baseUrl')"]: + if marker not in create_save: + fail(f"create-only save identity marker missing: {marker}") +for marker in ["xhr.status === 201 && opt.method === 'POST' && creation", "creation.generation === get(this, 'generation')", "creation.baseUrl === get(this, 'baseUrl')", "cached.get('id') === xhr.body.id", "get(cached, 'store') === this && this.hasRecord(cached)", "response = response || this._typeify(xhr.body);"]: + if marker not in request_success: + fail(f"same-store create response adoption marker missing: {marker}") +create_order_tests = (repo / "tests/unit/vendor/api-store-create-order-test.js").read_text(encoding="utf-8") +for marker in [ + "delayed 201 cannot overwrite the newer subscribe model, repeated with deterministic barriers 100 times", + "uncached creates retain the original response import path", + "subtype and base-type aliases adopt one saved model without regressing the subscribe fields", + "cached create adoption does not run stale mangleIn or nested resource imports", + "opaque case-sensitive IDs and exact concrete types do not borrow another canonical model", + "another project store, reset generation and changed API base cannot use create adoption", + "GET, PUT, action POST and non-201 responses preserve normal imports", + "204 and errors keep their HTTP semantics without importing a model", + "reusing save options cannot carry a create marker into an existing record save", + "action POST cannot reuse an old create marker even when the action returns 201", +]: + if marker not in create_order_tests: + fail(f"API-store create response order regression missing: {marker}") + for forbidden in [ "node_modules/ember-network", "node_modules/babel-traverse", @@ -426,7 +454,7 @@ for marker in [ deprecated = [path for path, item in packages.items() if item.get("deprecated")] print( "ui-ember-api-store-fetch-upgrade-ok " - f"version=2.8.5 api_store_compat_revision=4 ember-fetch=5.1.3 fetch_compat_revision=6 initializer_compat_revision=2 reference_compat_revision=2 " + f"version=2.8.5 api_store_compat_revision=5 ember-fetch=5.1.3 fetch_compat_revision=6 initializer_compat_revision=2 reference_compat_revision=2 " f"ember6_template_compat_revision=1 terminal_reconnect_revision=2 " f"deprecated_count={len(deprecated)} package_count={len(packages)}" ) diff --git a/scripts/check-ui-npm-audit.js b/scripts/check-ui-npm-audit.js new file mode 100644 index 0000000000..4d29313e07 --- /dev/null +++ b/scripts/check-ui-npm-audit.js @@ -0,0 +1,275 @@ +'use strict'; + +// npm severity is not rewritten. This is a time-bounded build-input decision, +// not a fix, a runtime VEX claim, or permission to ship the affected modules. +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const ADVISORY = 'https://github.com/advisories/GHSA-vfj7-8cjw-p6xm'; +const LEVELS = ['info', 'low', 'moderate', 'high', 'critical']; +const plain = v => v !== null && typeof v === 'object' && !Array.isArray(v); +const own = (v, k) => Object.prototype.hasOwnProperty.call(v, k); +const strings = v => Array.isArray(v) && v.every(x => typeof x === 'string' && x.length > 0) && new Set(v).size === v.length; +const sameSet = (a, b) => a.length === b.length && a.every(x => b.includes(x)); +const packageName = p => p.split('node_modules/').at(-1); +function need(ok, code) { if (!ok) throw new Error(code); } + +function resolveDependency(packages, from, name) { + let current = from; + while (true) { + const candidate = (current ? current + '/' : '') + 'node_modules/' + name; + if (own(packages, candidate)) return candidate; + if (!current) return null; + const parent = current.lastIndexOf('/node_modules/'); + current = parent < 0 ? '' : current.slice(0, parent); + } +} + +function validatePending(lock, pending, now) { + need(plain(pending) && pending.schemaVersion === 1 && pending.advisoryUrl === ADVISORY && + pending.severity === 'high' && pending.upstreamPatchedVersion === null && + pending.scope === 'controlled-dev-build-inputs-only' && pending.publicationBlockedIfShippedNodes === true && + strings(pending.shippedNodes) && pending.shippedNodes.length === 0, 'PENDING_POLICY_INVALID'); + need(pending.reviewUntil === '2026-10-10' && typeof pending.reviewedAt === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(pending.reviewedAt) && + Number.isFinite(Date.parse(pending.reviewedAt)) && Number.isFinite(now.getTime()) && + now.getTime() >= Date.parse(pending.reviewedAt) && now.getTime() < Date.parse(pending.reviewUntil + 'T00:00:00Z'), + 'PENDING_REVIEW_EXPIRED_OR_INVALID'); + need(plain(lock) && lock.lockfileVersion === 3 && plain(lock.packages) && plain(lock.packages['']), 'LOCK_SHAPE_INVALID'); + need(Array.isArray(pending.nodes) && pending.nodes.length > 0 && Array.isArray(pending.edges), 'PENDING_NODE_SHAPE_INVALID'); + const packages = lock.packages; + const pinned = pending.nodes.map(n => n.path); + need(strings(pinned) && pinned.includes('node_modules/braces'), 'PENDING_NODE_SHAPE_INVALID'); + for (const n of pending.nodes) { + need(plain(n) && /^node_modules\/(?:[^/]+\/node_modules\/)*[^/]+$/.test(n.path) && + typeof n.version === 'string' && typeof n.resolved === 'string' && typeof n.integrity === 'string' && n.dev === true, + 'PENDING_NODE_SHAPE_INVALID'); + const actual = packages[n.path]; + need(plain(actual) && ['version', 'resolved', 'integrity', 'dev'].every(k => actual[k] === n[k]) && + actual.link !== true && actual.devOptional !== true, 'LOCK_NODE_REVIEW_MISMATCH'); + } + const bracePaths = Object.keys(packages).filter(p => packageName(p) === 'braces'); + need(sameSet(bracePaths, ['node_modules/braces']) && packages['node_modules/braces'].version === '3.0.3', 'BRACES_NODE_MISMATCH'); + const edges = []; + for (const [from, node] of Object.entries(packages)) { + if (!from) continue; + for (const [name, spec] of Object.entries({ ...node.dependencies, ...node.optionalDependencies })) { + const to = resolveDependency(packages, from, name); + if (to) edges.push({ from, to, spec }); + } + } + const closure = new Set(bracePaths); + let changed = true; + while (changed) { + changed = false; + for (const e of edges) if (closure.has(e.to) && !closure.has(e.from)) { closure.add(e.from); changed = true; } + } + need(sameSet([...closure], pinned), 'LOCK_CLOSURE_REVIEW_MISMATCH'); + const edgeKey = e => JSON.stringify([e.from, e.to, e.spec]); + const actualEdges = edges.filter(e => closure.has(e.from) && closure.has(e.to)).map(edgeKey); + need(pending.edges.every(e => plain(e) && pinned.includes(e.from) && pinned.includes(e.to) && typeof e.spec === 'string') && + sameSet(actualEdges, pending.edges.map(edgeKey)) && new Set(pending.edges.map(edgeKey)).size === pending.edges.length, + 'LOCK_EDGE_REVIEW_MISMATCH'); + // dev:true is necessary but not sufficient: a production-root dependency + // reaching the pending closure also invalidates the build-only boundary. + const production = Object.keys({ ...packages[''].dependencies, ...packages[''].optionalDependencies }) + .map(n => resolveDependency(packages, '', n)).filter(Boolean); + const seen = new Set(production); + for (let i = 0; i < production.length; i++) { + const from = production[i]; + need(!closure.has(from), 'PENDING_NODE_SHIPPED'); + for (const e of edges) if (e.from === from && !seen.has(e.to)) { seen.add(e.to); production.push(e.to); } + } + return { packages, pinned, edges }; +} + +function validateReviewedImports(sources, pending) { + const names = new Set(pending.nodes.map(n => packageName(n.path))); + need(plain(sources) && Object.keys(sources).includes('ember-cli-build.js') && + Object.keys(sources).every(p => p === 'ember-cli-build.js' || /^(app|config|vendor)\/.*\.js$/.test(p)), + 'BROWSER_SOURCE_INPUT_INVALID'); + for (const [file, source] of Object.entries(sources)) { + need(typeof source === 'string', 'BROWSER_SOURCE_INPUT_INVALID'); + const imports = /\b(?:from\s*|require\s*\(\s*|import\s*\(\s*|import\s*|app\.import\s*\(\s*)['"]([^'"]+)['"]/g; + for (const match of source.matchAll(imports)) { + const spec = match[1].replace(/^node_modules\//, ''); + const name = spec.split('/')[0]; + if (!names.has(name)) continue; + need(file === 'ember-cli-build.js' && match[1] === 'ember-cli/lib/broccoli/ember-app', 'PENDING_NODE_BROWSER_IMPORT'); + } + } + // Static imports alone cannot prove artifact contents. Publication still + // requires the separate packaged-browser inventory; never emit notAffected. +} + +function readReviewedSources(repoRoot, io = fs) { + const sources = { 'ember-cli-build.js': io.readFileSync(path.join(repoRoot, 'ember-cli-build.js'), 'utf8') }; + function walk(relative) { + for (const entry of io.readdirSync(path.join(repoRoot, relative), { withFileTypes: true })) { + const file = relative + '/' + entry.name; + // Cold npm ci installs local-addon dependencies here, including Unix + // .bin symlinks. They are audited by the full lock/report, not owned JS. + // Only that exact directory boundary is excluded; source links and + // node_modules under app/config are still refused. + if (entry.name === 'node_modules' && entry.isDirectory()) { + need(/^vendor\/[^/]+$/.test(relative), 'BROWSER_SOURCE_DEPENDENCY_BOUNDARY_INVALID'); + continue; + } + need(!entry.isSymbolicLink(), 'BROWSER_SOURCE_SYMLINK_UNREVIEWED'); + if (entry.isDirectory()) walk(file); + else if (entry.isFile() && entry.name.endsWith('.js')) sources[file] = io.readFileSync(path.join(repoRoot, file), 'utf8'); + } + } + for (const directory of ['app', 'config', 'vendor']) walk(directory); + return sources; +} + +function evaluateAudit({ audit, lock, pending, now = new Date(), npmExitCode }) { + let totals = null; + try { + const { packages, pinned } = validatePending(lock, pending, now); + need(plain(audit) && audit.auditReportVersion === 2 && !own(audit, 'error') && + plain(audit.vulnerabilities) && plain(audit.metadata) && plain(audit.metadata.vulnerabilities) && + plain(audit.metadata.dependencies), 'NPM_AUDIT_SHAPE_OR_ERROR'); + const counts = audit.metadata.vulnerabilities; + need(sameSet(Object.keys(counts), [...LEVELS, 'total']) && + [...LEVELS, 'total'].every(k => Number.isSafeInteger(counts[k]) && counts[k] >= 0) && + LEVELS.reduce((n, k) => n + counts[k], 0) === counts.total, 'NPM_AUDIT_TOTALS_INVALID'); + totals = Object.fromEntries([...LEVELS, 'total'].map(k => [k, counts[k]])); + // A reported Critical always blocks before meta-severity consistency; + // a newly promoted child must not be hidden by its old High wrappers. + need(totals.critical === 0, 'CRITICAL_VULNERABILITY'); + need(sameSet(Object.keys(audit.metadata.dependencies), ['prod', 'dev', 'optional', 'peer', 'peerOptional', 'total']) && + Object.values(audit.metadata.dependencies).every(n => Number.isSafeInteger(n) && n >= 0), 'NPM_AUDIT_DEPENDENCIES_INVALID'); + const vulnerabilities = audit.vulnerabilities; + const observed = Object.fromEntries(LEVELS.map(k => [k, 0])); + const allowedKeys = ['name', 'severity', 'isDirect', 'via', 'effects', 'range', 'nodes', 'fixAvailable']; + for (const [name, v] of Object.entries(vulnerabilities)) { + need(plain(v) && Object.keys(v).every(k => allowedKeys.includes(k)) && v.name === name && LEVELS.includes(v.severity) && + typeof v.isDirect === 'boolean' && typeof v.range === 'string' && strings(v.nodes) && v.nodes.length > 0 && + strings(v.effects) && Array.isArray(v.via) && v.via.length > 0 && + (typeof v.fixAvailable === 'boolean' || (plain(v.fixAvailable) && typeof v.fixAvailable.name === 'string' && + typeof v.fixAvailable.version === 'string' && typeof v.fixAvailable.isSemVerMajor === 'boolean')), 'NPM_VULNERABILITY_SHAPE_INVALID'); + observed[v.severity]++; + for (const node of v.nodes) need(plain(packages[node]) && packageName(node) === name, 'AUDIT_NODE_LOCK_MISMATCH'); + for (const e of v.effects) need(own(vulnerabilities, e), 'AUDIT_EFFECT_REFERENCE_INVALID'); + need(new Set(v.via.map(x => typeof x === 'string' ? 'meta:' + x : 'advisory:' + x?.url)).size === v.via.length, + 'AUDIT_VIA_DUPLICATE'); + for (const via of v.via) { + if (typeof via === 'string') { + need(own(vulnerabilities, via), 'AUDIT_VIA_REFERENCE_INVALID'); + need(LEVELS.indexOf(v.severity) >= LEVELS.indexOf(vulnerabilities[via].severity), 'AUDIT_SEVERITY_INCONSISTENT'); + } + else need(plain(via) && Object.keys(via).every(k => ['source', 'name', 'dependency', 'title', 'url', 'severity', 'cwe', 'cvss', 'range'].includes(k)) && + Number.isSafeInteger(via.source) && via.source > 0 && via.name === name && + via.dependency === name && typeof via.title === 'string' && typeof via.url === 'string' && + /^https:\/\/github\.com\/advisories\/GHSA-[a-z0-9-]+$/.test(via.url) && LEVELS.includes(via.severity) && + typeof via.range === 'string' && strings(via.cwe) && plain(via.cvss) && Number.isFinite(via.cvss.score) && + (via.cvss.vectorString === null || typeof via.cvss.vectorString === 'string'), 'AUDIT_ADVISORY_SHAPE_INVALID'); + if (typeof via !== 'string') need(LEVELS.indexOf(v.severity) >= LEVELS.indexOf(via.severity), 'AUDIT_SEVERITY_INCONSISTENT'); + } + } + need(LEVELS.every(k => observed[k] === totals[k]), 'NPM_AUDIT_TOTALS_MISMATCH'); + need(npmExitCode === (totals.high + totals.critical > 0 ? 1 : 0), 'NPM_EXIT_OR_NETWORK_ERROR'); + const visiting = new Set(); + const verified = new Set(); + function knownClosure(name) { + if (verified.has(name)) return; + need(!visiting.has(name), 'METAVULNERABILITY_CYCLE'); + visiting.add(name); + const v = vulnerabilities[name]; + need(v.severity === 'high' && v.nodes.every(n => pinned.includes(n)), 'UNREVIEWED_HIGH_NODE'); + for (const via of v.via) { + if (typeof via === 'string') { + need(v.nodes.every(from => { + const spec = { ...packages[from].dependencies, ...packages[from].optionalDependencies }[via]; + return typeof spec === 'string' && vulnerabilities[via].nodes.includes(resolveDependency(packages, from, via)); + }), 'METAVULNERABILITY_LOCK_EDGE_MISMATCH'); + knownClosure(via); + } else { + need(name === 'braces' && via.name === 'braces' && via.dependency === 'braces' && via.severity === 'high' && + via.url === ADVISORY && via.range === '<=3.0.3', 'UNREVIEWED_DIRECT_ADVISORY'); + } + } + visiting.delete(name); + verified.add(name); + } + const high = Object.keys(vulnerabilities).filter(n => vulnerabilities[n].severity === 'high'); + for (const name of high) knownClosure(name); + if (high.length) { + need(sameSet(high.flatMap(n => vulnerabilities[n].nodes), pinned), 'AUDIT_HIGH_CLOSURE_INCOMPLETE'); + for (const name of high) { + const parents = high.filter(n => vulnerabilities[n].via.includes(name)); + need(sameSet(vulnerabilities[name].effects, parents), 'AUDIT_METAVULNERABILITY_EFFECTS_MISMATCH'); + } + } + return { ok: true, outcome: high.length ? 'PASS_BUILD_VENDOR_PENDING' : 'PASS_HIGH_CRITICAL_CLEAN', totals, + knownPending: high.length ? { advisory: 'GHSA-vfj7-8cjw-p6xm', severity: 'high', vulnerableNodeCount: 1, + metavulnerabilityCount: high.length - 1, reviewUntil: pending.reviewUntil, upstreamPatchedVersion: null } : null, + runtimeNotAffectedClaim: false }; + } catch (error) { + return { ok: false, outcome: 'FAIL_CLOSED', totals, failureCode: /^[A-Z0-9_]+$/.test(error.message) ? error.message : 'LOCAL_EVALUATION_ERROR' }; + } +} + +function npmInvocation(platform, versionOnly = false) { + // The SDK on PATH is the toolchain trust boundary on both platforms. + // Never discover executable files beneath an environment-provided root. + // cmd is needed for npm.cmd; /d disables AutoRun and every argument is literal. + if (platform === 'win32') return { command: 'cmd.exe', args: ['/d', '/s', '/c', + versionOnly ? 'npm --version' : 'npm audit --audit-level=high --json'] }; + return { command: 'npm', args: versionOnly ? ['--version'] : ['audit', '--audit-level=high', '--json'] }; +} + +function runNpm(invocation, options, runner, platform) { + if (platform !== 'win32') return runner(invocation.command, invocation.args, options); + // libuv resolves the launcher using the parent's environment; cmd then + // resolves npm in the child. A child-only env override protects only npm. + // This synchronous boundary guards both lookups and restores every exit. + const previous = process.env.NoDefaultCurrentDirectoryInExePath; + process.env.NoDefaultCurrentDirectoryInExePath = '1'; + try { return runner(invocation.command, invocation.args, options); } + finally { + if (previous === undefined) delete process.env.NoDefaultCurrentDirectoryInExePath; + else process.env.NoDefaultCurrentDirectoryInExePath = previous; + } +} + +function runAudit(repoRoot, runner = spawnSync, platform = process.platform) { + let lock, pending; + try { + lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8')); + pending = JSON.parse(fs.readFileSync(path.join(repoRoot, 'docs/security/npm-vendor-pending.json'), 'utf8')); + validatePending(lock, pending, new Date()); + validateReviewedImports(readReviewedSources(repoRoot), pending); + } catch (error) { + return { ok: false, outcome: 'FAIL_CLOSED', totals: null, + failureCode: /^[A-Z0-9_]+$/.test(error.message) ? error.message : 'PREFLIGHT_INPUT_ERROR' }; + } + const options = { cwd: repoRoot, encoding: 'utf8', shell: false, timeout: 120000, maxBuffer: 8 * 1024 * 1024 }; + const version = npmInvocation(platform, true); + let installed; + try { installed = runNpm(version, options, runner, platform); } + catch (_) { return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_PROCESS_ERROR' }; } + if (!installed || installed.error || installed.signal || installed.status !== 0 || + typeof installed.stdout !== 'string' || installed.stdout.trim() !== '12.0.2') + return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_VERSION_REQUIRED' }; + const invocation = npmInvocation(platform); + let result; + try { result = runNpm(invocation, options, runner, platform); } + catch (_) { return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_PROCESS_ERROR' }; } + if (!result || result.error || result.signal || ![0, 1].includes(result.status)) + return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_EXIT_OR_NETWORK_ERROR' }; + let audit; + try { audit = JSON.parse(result.stdout); } + catch (_) { return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_JSON_INVALID' }; } + return evaluateAudit({ audit, lock, pending, npmExitCode: result.status }); +} + +module.exports = { evaluateAudit, validatePending, validateReviewedImports, readReviewedSources, npmInvocation, runAudit }; +if (require.main === module) { + const result = runAudit(path.resolve(__dirname, '..')); + console.log(JSON.stringify(result)); + process.exitCode = result.ok ? 0 : 1; +} diff --git a/scripts/ci b/scripts/ci index f1d5977ffa..8f9cded7e6 100755 --- a/scripts/ci +++ b/scripts/ci @@ -25,7 +25,8 @@ node ./scripts/check-ui-localization-quality ./scripts/check-ui-oidc-safe-activation ./scripts/check-ui-critical-high-dependencies node ./scripts/check-ui-security-patch-compat.js -npm audit --audit-level=high +node ./scripts/test-ui-npm-audit.js +node ./scripts/check-ui-npm-audit.js ./scripts/check-ui-codeql-critical-high ./scripts/check-dependency-baseline ./scripts/check-sass-replacement diff --git a/scripts/node24-lock-smoke.js b/scripts/node24-lock-smoke.js index 3b8e675c6b..9f85ad89c4 100644 --- a/scripts/node24-lock-smoke.js +++ b/scripts/node24-lock-smoke.js @@ -701,7 +701,7 @@ function expectEmberApiStoreFetchUpgrade() { if (JSON.stringify(apiStoreInfo.dependencies) !== JSON.stringify(expectedApiStoreDependencies)) { fail(`ember-api-store reviewed dependency boundary changed: ${JSON.stringify(apiStoreInfo.dependencies)}`); } - if (!apiStoreInfo.pasturestackCompatibility || apiStoreInfo.pasturestackCompatibility.revision !== 4) { + if (!apiStoreInfo.pasturestackCompatibility || apiStoreInfo.pasturestackCompatibility.revision !== 5) { fail("ember-api-store compatibility revision is missing"); } if (!emberFetchInfo.pasturestackCompatibility || emberFetchInfo.pasturestackCompatibility.revision !== 6) { @@ -738,6 +738,40 @@ function expectEmberApiStoreFetchUpgrade() { fail("ember-api-store deferred request initialization fix is missing"); } + expectVendoredFileSha256("vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz", + "90da9ebdc36a8069629086d011e799691ace8f88c13d9c9df1333c77015a2ab8"); + const typeMixin = fs.readFileSync(path.join(apiStoreDir, "addon/mixins/type.js"), "utf8"); + const actionDispatch = typeMixin.split(" doAction: function(name, data, opt) {")[1].split(" save: function(opt) {")[0]; + if (!actionDispatch.includes("delete opt.createIdentity;")) { + fail("ember-api-store action POST must clear any reused create identity"); + } + for (const marker of ["delete opt.createIdentity;", "if ( opt.method === 'POST' )", "opt.createIdentity = {", + "generation: get(store, 'generation')", "baseUrl: get(store, 'baseUrl')"]) { + if (!typeMixin.includes(marker)) { + fail(`ember-api-store create-only save identity marker missing: ${marker}`); + } + } + for (const marker of ["xhr.status === 201 && opt.method === 'POST' && creation", + "creation.generation === get(this, 'generation')", "creation.baseUrl === get(this, 'baseUrl')", + "cached.get('id') === xhr.body.id", "get(cached, 'store') === this && this.hasRecord(cached)", + "response = response || this._typeify(xhr.body);"]) { + if (!storeService.includes(marker)) { + fail(`ember-api-store same-store create response adoption marker missing: ${marker}`); + } + } + const createOrderTests = fs.readFileSync("tests/unit/vendor/api-store-create-order-test.js", "utf8"); + for (const marker of ["delayed 201 cannot overwrite the newer subscribe model", + "cached create adoption does not run stale mangleIn or nested resource imports", + "another project store, reset generation and changed API base cannot use create adoption", + "GET, PUT, action POST and non-201 responses preserve normal imports", + "204 and errors keep their HTTP semantics without importing a model", + "reusing save options cannot carry a create marker into an existing record save", + "action POST cannot reuse an old create marker even when the action returns 201"]) { + if (!createOrderTests.includes(marker)) { + fail(`ember-api-store create response order regression missing: ${marker}`); + } + } + const fetchRuntimePath = path.join(emberFetchDir, "vendor/ember-fetch.js"); const fetchRuntime = fs.readFileSync(fetchRuntimePath, "utf8"); for (const marker of [ @@ -797,7 +831,7 @@ function expectEmberApiStoreFetchUpgrade() { fail("ember-fetch native production wrapper smoke failed"); } - console.log("ember-api-store-fetch-upgrade-smoke-ok version=2.8.5 api_store_compat_revision=4 ember-fetch=5.1.3 fetch_compat_revision=6 native_fetch=ok legacy_build_graph=absent"); + console.log("ember-api-store-fetch-upgrade-smoke-ok version=2.8.5 api_store_compat_revision=5 ember-fetch=5.1.3 fetch_compat_revision=6 native_fetch=ok legacy_build_graph=absent"); } function expectBrowserGlobalBundle(file, globalName, expectedVersion) { diff --git a/scripts/test-ui-npm-audit.js b/scripts/test-ui-npm-audit.js new file mode 100644 index 0000000000..868ace7302 --- /dev/null +++ b/scripts/test-ui-npm-audit.js @@ -0,0 +1,300 @@ +'use strict'; + +// Deterministic npm12 report shapes; no install, audit, registry, or build. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const { evaluateAudit, validateReviewedImports, readReviewedSources, npmInvocation, runAudit } = require('./check-ui-npm-audit'); +const root = path.resolve(__dirname, '..'); +const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8')); +const pending = JSON.parse(fs.readFileSync(path.join(root, 'docs/security/npm-vendor-pending.json'), 'utf8')); +const clone = value => JSON.parse(JSON.stringify(value)); +const now = new Date('2026-10-04T00:00:00Z'); +const dependencyCounts = { prod: 8, dev: 1454, optional: 18, peer: 1, peerOptional: 0, total: 1477 }; +const knownVia = { + braces: [{ source: 1240992, name: 'braces', dependency: 'braces', + title: 'braces vulnerable to stack-exhaustion denial of service through deeply nested patterns', + url: pending.advisoryUrl, severity: 'high', cwe: ['CWE-674'], + cvss: { score: 7.5, vectorString: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H' }, range: '<=3.0.3' }], + micromatch: ['braces'], + 'findup-sync': ['micromatch'], + 'find-yarn-workspace-root': ['micromatch'], + sane: ['micromatch'], + broccoli: ['findup-sync', 'sane'], + 'ember-cli': ['broccoli', 'find-yarn-workspace-root', 'sane'] +}; +function report(withHigh = true, withModerate = false) { + const vulnerabilities = {}; + if (withHigh) for (const [name, via] of Object.entries(knownVia)) vulnerabilities[name] = { + name, severity: 'high', isDirect: name === 'ember-cli', via: clone(via), + effects: Object.keys(knownVia).filter(n => knownVia[n].includes(name)), range: '*', nodes: ['node_modules/' + name], + fixAvailable: { name: 'ember-cli', version: '3.3.0', isSemVerMajor: true } + }; + if (withModerate) vulnerabilities['fast-uri'] = { + name: 'fast-uri', severity: 'moderate', isDirect: false, + via: [{ source: 1240091, name: 'fast-uri', dependency: 'fast-uri', title: 'Separate Moderate advisory', + url: 'https://github.com/advisories/GHSA-hrr3-gc8f-f4qj', severity: 'moderate', cwe: ['CWE-178'], + cvss: { score: 4.8, vectorString: null }, range: '>=3.0.0 <3.1.8' }], + effects: [], range: '3.0.0 - 3.1.7', nodes: ['node_modules/fast-uri'], fixAvailable: true + }; + return { auditReportVersion: 2, vulnerabilities, + metadata: { vulnerabilities: { info: 0, low: 0, moderate: withModerate ? 1 : 0, high: withHigh ? 7 : 0, + critical: 0, total: (withHigh ? 7 : 0) + (withModerate ? 1 : 0) }, dependencies: clone(dependencyCounts) } }; +} +function input(audit = report()) { return { audit, lock: clone(lock), pending: clone(pending), now, npmExitCode: audit.metadata.vulnerabilities.high ? 1 : 0 }; } +function fail(value, code) { + const result = evaluateAudit(value); + assert.equal(result.ok, false); + assert.equal(result.outcome, 'FAIL_CLOSED'); + if (code) assert.equal(result.failureCode, code); + return result; +} + +test('clean High/Critical report passes without claiming zero Moderate or runtime unaffected', () => { + const result = evaluateAudit(input(report(false, true))); + assert.equal(result.ok, true); + assert.equal(result.outcome, 'PASS_HIGH_CRITICAL_CLEAN'); + assert.equal(result.totals.moderate, 1); + assert.equal(result.knownPending, null); + assert.equal(result.runtimeNotAffectedClaim, false); +}); +test('exact actual seven-node npm12 closure passes and keeps raw High plus separate Moderate', () => { + const result = evaluateAudit(input(report(true, true))); + assert.equal(result.ok, true); + assert.equal(result.outcome, 'PASS_BUILD_VENDOR_PENDING'); + assert.equal(result.totals.high, 7); + assert.equal(result.totals.moderate, 1); + assert.equal(result.totals.critical, 0); + assert.equal(result.knownPending.metavulnerabilityCount, 6); + assert.equal(result.knownPending.upstreamPatchedVersion, null); +}); +test('unknown High or extra direct advisory cannot borrow the known package name', () => { + const value = input(); + value.audit.vulnerabilities.braces.via[0].url = 'https://github.com/advisories/GHSA-aaaa-bbbb-cccc'; + fail(value, 'UNREVIEWED_DIRECT_ADVISORY'); + const extra = input(); + extra.audit.vulnerabilities.braces.via.push({ ...extra.audit.vulnerabilities.braces.via[0], source: 99, + url: 'https://github.com/advisories/GHSA-aaaa-bbbb-cccc' }); + fail(extra, 'UNREVIEWED_DIRECT_ADVISORY'); +}); +test('Critical always blocks, including a known advisory promoted to Critical', () => { + const value = input(); + value.audit.vulnerabilities.braces.severity = 'critical'; + value.audit.metadata.vulnerabilities.high--; + value.audit.metadata.vulnerabilities.critical++; + assert.equal(fail(value, 'CRITICAL_VULNERABILITY').totals.critical, 1); +}); + +test('a Moderate wrapper cannot conceal a High or Critical direct or meta advisory', () => { + for (const severity of ['high', 'critical']) { + const value = input(report(true, true)); + value.audit.vulnerabilities['fast-uri'].via[0].severity = severity; + fail(value, 'AUDIT_SEVERITY_INCONSISTENT'); + } + const meta = input(report(true, true)); + meta.audit.vulnerabilities['fast-uri'].via = ['braces']; + fail(meta, 'AUDIT_SEVERITY_INCONSISTENT'); +}); +test('version, resolved URL, integrity, and dev flag changes each invalidate exact node review', () => { + for (const [key, val] of [['version', '3.0.4'], ['resolved', 'https://example.invalid/braces.tgz'], + ['integrity', 'sha512-different'], ['dev', false]]) { + const value = input(); value.lock.packages['node_modules/braces'][key] = val; + fail(value, 'LOCK_NODE_REVIEW_MISMATCH'); + } +}); +test('root version changes do not stale unchanged dependency review', () => { + const value = input(); value.lock.packages[''].version = '1.6.999'; + assert.equal(evaluateAudit(value).ok, true); +}); +test('production reachability or shipped node blocks publication boundary', () => { + const value = input(); value.lock.packages[''].dependencies.braces = '3.0.3'; + fail(value, 'PENDING_NODE_SHIPPED'); + const shipped = input(); shipped.pending.shippedNodes = ['node_modules/braces']; + fail(shipped, 'PENDING_POLICY_INVALID'); +}); +test('additional braces major/node, consumer closure, or changed edge rejects rather than broadening exception', () => { + const value = input(); value.lock.packages['node_modules/other/node_modules/braces'] = clone(value.lock.packages['node_modules/braces']); + fail(value, 'BRACES_NODE_MISMATCH'); + const consumer = input(); consumer.lock.packages['node_modules/other'] = { dev: true, version: '1.0.0', dependencies: { braces: '^3.0.3' } }; + fail(consumer, 'LOCK_CLOSURE_REVIEW_MISMATCH'); + const edge = input(); edge.lock.packages['node_modules/micromatch'].dependencies.braces = '*'; + fail(edge, 'LOCK_EDGE_REVIEW_MISMATCH'); +}); +test('all meta branches must resolve actual lock dependencies and exact advisory', () => { + const value = input(); value.audit.vulnerabilities.broccoli.via.push('braces'); + fail(value, 'METAVULNERABILITY_LOCK_EDGE_MISMATCH'); + const missing = input(); missing.audit.vulnerabilities.micromatch.via = ['missing-package']; + fail(missing, 'AUDIT_VIA_REFERENCE_INVALID'); +}); +test('meta cycle rejects even when synthetic lock graph supplies that edge', () => { + const value = input(); + value.lock.packages['node_modules/braces'].dependencies.micromatch = '^4.0.8'; + value.pending.edges.push({ from: 'node_modules/braces', to: 'node_modules/micromatch', spec: '^4.0.8' }); + value.audit.vulnerabilities.braces.via = ['micromatch']; + fail(value, 'METAVULNERABILITY_CYCLE'); +}); +test('missing closure node or inconsistent effects cannot manufacture a complete pending decision', () => { + const value = input(); delete value.audit.vulnerabilities['ember-cli']; + for (const v of Object.values(value.audit.vulnerabilities)) v.effects = v.effects.filter(n => n !== 'ember-cli'); + value.audit.metadata.vulnerabilities.high--; value.audit.metadata.vulnerabilities.total--; + fail(value, 'AUDIT_HIGH_CLOSURE_INCOMPLETE'); + const effects = input(); effects.audit.vulnerabilities.braces.effects = []; + fail(effects, 'AUDIT_METAVULNERABILITY_EFFECTS_MISMATCH'); +}); +test('review expiration boundary is UTC and future/invalid dates reject', () => { + for (const at of ['2026-10-10T00:00:00Z', '2026-10-11T00:00:00Z', '2026-10-02T00:00:00Z', 'invalid']) { + const value = input(); value.now = new Date(at); fail(value, 'PENDING_REVIEW_EXPIRED_OR_INVALID'); + } +}); +test('npm error, malformed report, unknown shape, missing metadata and bad totals reject safely', () => { + const error = input(); error.audit.error = { summary: 'secret-stderr-marker' }; fail(error, 'NPM_AUDIT_SHAPE_OR_ERROR'); + const shape = input(); shape.audit.auditReportVersion = 1; fail(shape, 'NPM_AUDIT_SHAPE_OR_ERROR'); + const meta = input(); delete meta.audit.metadata; fail(meta, 'NPM_AUDIT_SHAPE_OR_ERROR'); + const extra = input(); extra.audit.vulnerabilities.braces.unknown = true; fail(extra, 'NPM_VULNERABILITY_SHAPE_INVALID'); + const totals = input(); totals.audit.metadata.vulnerabilities.high = 0; totals.audit.metadata.vulnerabilities.total = 0; + fail(totals, 'NPM_AUDIT_TOTALS_MISMATCH'); +}); +test('npm non-audit failures and inconsistent exit status never become allowed pending', () => { + for (const status of [null, 2, 127, 0]) { const value = input(); value.npmExitCode = status; fail(value, 'NPM_EXIT_OR_NETWORK_ERROR'); } +}); +test('main runner truly selects high JSON audit once, never install/fix, and emits no raw stderr/error', () => { + for (const platform of ['linux', 'win32']) { + let calls = 0; + const result = runAudit(root, (command, args, options) => { + calls++; + assert.deepEqual({ command, args }, npmInvocation(platform, calls === 1)); + assert.equal(options.shell, false); + assert.equal(options.cwd, root); + assert.equal(args.includes('fix'), false); + if (calls === 1) return { status: 0, stdout: '12.0.2\n' }; + return { status: 1, stdout: JSON.stringify(report()), stderr: 'private stderr marker' }; + }, platform); + assert.equal(calls, 2, JSON.stringify(result)); assert.equal(result.ok, true); + assert.equal(JSON.stringify(result).includes('private'), false); + } +}); +test('runner malformed JSON, network errors and thrown process errors are finite safe codes with no retry', () => { + for (const returned of [{ status: 1, stdout: 'secret invalid body' }, + { status: 1, stdout: JSON.stringify({ error: { code: 'ENOTFOUND', detail: 'secret' } }) }, + { status: null, error: new Error('secret network failure'), stdout: '' }]) { + let calls = 0; + const result = runAudit(root, () => { calls++; return calls === 1 ? { status: 0, stdout: '12.0.2\n' } : returned; }); + assert.equal(calls, 2, JSON.stringify(result)); assert.equal(result.ok, false); + assert.equal(JSON.stringify(result).includes('secret'), false); + } + let calls = 0; + const result = runAudit(root, () => { calls++; throw new Error('secret thrown failure'); }); + assert.equal(calls, 1, JSON.stringify(result)); assert.equal(result.failureCode, 'NPM_PROCESS_ERROR'); + let auditCalls = 0; + const auditThrow = runAudit(root, () => { + auditCalls++; + if (auditCalls === 1) return { status: 0, stdout: '12.0.2\n' }; + throw new Error('secret audit thrown failure'); + }); + assert.equal(auditCalls, 2); assert.equal(auditThrow.failureCode, 'NPM_PROCESS_ERROR'); +}); + +test('npm version failures stop before audit on both platforms without retry or raw output', () => { + for (const platform of ['linux', 'win32']) for (const returned of [null, + { status: 1, stdout: '12.0.2' }, { status: 0, stdout: '11.0.0' }, + { status: 0, stdout: '12.0.2 secret additional output' }, { status: 0 }, + { status: 0, stdout: '12.0.2', signal: 'SIGTERM' }, + { status: 0, stdout: '12.0.2', error: new Error('secret process failure') }]) { + let calls = 0; + const result = runAudit(root, () => { calls++; return returned; }, platform); + assert.equal(calls, 1); assert.equal(result.failureCode, 'NPM_VERSION_REQUIRED'); + assert.equal(JSON.stringify(result).includes('secret'), false); + } +}); + +test('npm command mapping is literal and never probes APPDATA or executable metadata paths', () => { + assert.deepEqual(npmInvocation('linux', true), { command: 'npm', args: ['--version'] }); + assert.deepEqual(npmInvocation('linux'), { command: 'npm', args: ['audit', '--audit-level=high', '--json'] }); + assert.deepEqual(npmInvocation('win32', true), { command: 'cmd.exe', args: ['/d', '/s', '/c', 'npm --version'] }); + assert.deepEqual(npmInvocation('win32'), { command: 'cmd.exe', args: ['/d', '/s', '/c', 'npm audit --audit-level=high --json'] }); + const saved = process.env.APPDATA; + const oldExists = fs.existsSync; + try { + fs.existsSync = () => { throw new Error('Environment executable path must not be probed'); }; + for (const value of ['../../outside', 'C:\\untrusted\\npm & injected', '']) { + process.env.APPDATA = value; + let calls = 0; + const result = runAudit(root, (command, args) => { + calls++; + assert.deepEqual({ command, args }, npmInvocation('win32', calls === 1)); + return calls === 1 ? { status: 0, stdout: '12.0.2\n' } : { status: 1, stdout: JSON.stringify(report()) }; + }, 'win32'); + assert.equal(calls, 2); assert.equal(result.ok, true); + } + } finally { + fs.existsSync = oldExists; + if (saved === undefined) delete process.env.APPDATA; + else process.env.APPDATA = saved; + } +}); + +test('Windows launcher and npm skip implicit cwd while synchronous environment ownership is restored', () => { + const saved = process.env.NoDefaultCurrentDirectoryInExePath; + try { + for (const previous of [undefined, '', 'existing-value']) for (const failure of ['none', 'version', 'audit']) { + if (previous === undefined) delete process.env.NoDefaultCurrentDirectoryInExePath; + else process.env.NoDefaultCurrentDirectoryInExePath = previous; + let calls = 0; + const result = runAudit(root, () => { + calls++; + assert.equal(process.env.NoDefaultCurrentDirectoryInExePath, '1'); + if ((calls === 1 && failure === 'version') || (calls === 2 && failure === 'audit')) throw new Error('safe synthetic failure'); + return calls === 1 ? { status: 0, stdout: '12.0.2\n' } : { status: 1, stdout: JSON.stringify(report()) }; + }, 'win32'); + assert.equal(process.env.NoDefaultCurrentDirectoryInExePath, previous); + assert.equal(calls, failure === 'version' ? 1 : 2); + assert.equal(result.ok, failure === 'none'); + if (failure !== 'none') assert.equal(result.failureCode, 'NPM_PROCESS_ERROR'); + } + } finally { + if (saved === undefined) delete process.env.NoDefaultCurrentDirectoryInExePath; + else process.env.NoDefaultCurrentDirectoryInExePath = saved; + } +}); +test('reviewed runtime import boundary refuses every pending consumer while exact Ember build entry is allowed', () => { + const sources = { 'ember-cli-build.js': "var EmberApp = require('ember-cli/lib/broccoli/ember-app');", + 'app/app.js': "import App from '@ember/application';", 'vendor/example.js': 'const braces = [1, 2];' }; + assert.doesNotThrow(() => validateReviewedImports(sources, pending)); + for (const node of pending.nodes) { + const name = node.path.slice('node_modules/'.length); + for (const content of ["import x from '" + name + "';", "require('" + name + "/index.js');", + "import('" + name + "');", "app.import('node_modules/" + name + "/index.js');"]) { + assert.throws(() => validateReviewedImports({ ...sources, 'app/changed.js': content }, pending), + { message: 'PENDING_NODE_BROWSER_IMPORT' }); + } + } + assert.throws(() => validateReviewedImports({ ...sources, 'ember-cli-build.js': "app.import('node_modules/braces/index.js');" }, pending), + { message: 'PENDING_NODE_BROWSER_IMPORT' }); +}); + +test('cold-installed addon node_modules are audited packages, while owned source symlinks remain blocked', () => { + const entry = (name, kind) => ({ name, isDirectory: () => kind === 'dir', + isFile: () => kind === 'file', isSymbolicLink: () => kind === 'link' }); + const tree = { app: [entry('app.js', 'file')], config: [], + vendor: [entry('lacsso', 'dir')], + 'vendor/lacsso': [entry('index.js', 'file'), entry('node_modules', 'dir')], + 'vendor/lacsso/node_modules': [entry('.bin', 'dir')], + 'vendor/lacsso/node_modules/.bin': [entry('rimraf', 'link')] }; + const visited = []; + const io = { readFileSync: () => '', readdirSync: file => { + const relative = path.relative(root, file).split(path.sep).join('/'); + visited.push(relative); return tree[relative]; + } }; + const sources = readReviewedSources(root, io); + assert.deepEqual(Object.keys(sources), ['ember-cli-build.js', 'app/app.js', 'vendor/lacsso/index.js']); + assert.equal(visited.includes('vendor/lacsso/node_modules'), false); + tree['vendor/lacsso'].push(entry('source.js', 'link')); + assert.throws(() => readReviewedSources(root, io), { message: 'BROWSER_SOURCE_SYMLINK_UNREVIEWED' }); + tree['vendor/lacsso'].pop(); + tree.app.push(entry('node_modules', 'dir')); + assert.throws(() => readReviewedSources(root, io), { message: 'BROWSER_SOURCE_DEPENDENCY_BOUNDARY_INVALID' }); + tree.app.pop(); + tree['vendor/lacsso'][1] = entry('node_modules', 'link'); + assert.throws(() => readReviewedSources(root, io), { message: 'BROWSER_SOURCE_SYMLINK_UNREVIEWED' }); +}); diff --git a/tests/unit/vendor/api-store-create-order-test.js b/tests/unit/vendor/api-store-create-order-test.js new file mode 100644 index 0000000000..ec45c59669 --- /dev/null +++ b/tests/unit/vendor/api-store-create-order-test.js @@ -0,0 +1,225 @@ +import { module, test } from 'qunit'; +import { setOwner } from '@ember/application'; +import { run } from '@ember/runloop'; +import { defer, resolve } from 'rsvp'; +import Store from 'ember-api-store/services/store'; +import Resource from 'ember-api-store/models/resource'; +import Schema from 'ember-api-store/models/schema'; +import Collection from 'ember-api-store/models/collection'; + +// Real installed compatibility package and Type.save. Only the HTTP boundary +// is deferred: subscribe import must complete before the original 201 arrives. +function fixture(project = '1a-test') { + const objects = new Set(); + const requests = []; + const store = Store.create({ baseUrl: `/v2-beta/projects/${project}` }); + setOwner(store, { lookup(name) { + if ( name === 'service:fastboot' ) { return { isFastBoot: false }; } + const Factory = name === 'model:schema' ? Schema : + name === 'model:collection' ? Collection : Resource; + const object = Factory.create(); + objects.add(object); + return object; + } }); + const createRecord = store.createRecord.bind(store); + store.createRecord = (...args) => { + const object = createRecord(...args); + objects.add(object); + return object; + }; + const response = defer(); + store.rawRequest = (options) => { requests.push(options); return response.promise; }; + store._bulkAdd('schema', ['volume', 'loadBalancerService', 'service'].map(id => ({ + type: 'schema', id, resourceFields: {}, collectionMethods: ['GET', 'POST'], + links: { collection: `${store.baseUrl}/${id}s` }, + }))); + return { store, requests, response, + destroy() { + store.all('schema').forEach(object => objects.add(object)); + run(() => { + objects.forEach(object => { if ( !object.isDestroyed ) { object.destroy(); } }); + store.destroy(); + }); + }, + }; +} + +const initial = (type = 'volume', id = 'Opaque-ID') => ({ + type, id, accountId: '1a-test', name: 'created', state: 'registering', externalId: null, +}); +const current = (type = 'volume', id = 'Opaque-ID') => ({ + ...initial(type, id), state: 'inactive', externalId: 'created', +}); + +module('Unit | Vendor | API store create response order', function() { + test('delayed 201 cannot overwrite the newer subscribe model, repeated with deterministic barriers 100 times', async function(assert) { + for ( let index = 0; index < 100; index++ ) { + const f = fixture(); + try { + const draft = f.store.createRecord({type: 'volume', name: 'created'}); + const saving = run(() => draft.save()); + assert.strictEqual(f.requests.length, 1, 'one create dispatch, no extra GET'); + const live = run(() => f.store._typeify(current())); + const xhr = {status: 201, body: initial()}; + run(() => f.response.resolve(xhr)); + const saved = await saving; + assert.strictEqual(saved, draft, 'existing save completion identity retained'); + assert.strictEqual(saved.get('state'), 'inactive'); + assert.strictEqual(saved.get('externalId'), 'created'); + assert.strictEqual(f.store.getById('volume', 'Opaque-ID'), draft); + assert.strictEqual(f.store.all('volume').get('length'), 1, 'no duplicate canonical resource'); + assert.strictEqual(live.get('xhr'), xhr, 'actual HTTP metadata remains attached'); + assert.strictEqual(f.requests[0].responseStatus, 201); + assert.notOk(Object.hasOwn(f.requests[0].data, 'createIdentity'), 'internal marker is not payload'); + } finally { f.destroy(); } + } + }); + + test('uncached creates retain the original response import path', async function(assert) { + const f = fixture(); + try { + const draft = f.store.createRecord({type: 'volume', name: 'created'}); + const saving = run(() => draft.save()); + run(() => f.response.resolve({status: 201, body: initial()})); + assert.strictEqual(await saving, draft); + assert.strictEqual(draft.get('state'), 'registering'); + assert.strictEqual(f.store.getById('volume', 'Opaque-ID'), draft); + assert.strictEqual(f.requests.length, 1); + } finally { f.destroy(); } + }); + + test('subtype and base-type aliases adopt one saved model without regressing the subscribe fields', async function(assert) { + const f = fixture(); + try { + const draft = f.store.createRecord({type: 'loadBalancerService', baseType: 'service', name: 'created'}); + const saving = run(() => draft.save()); + run(() => f.store._typeify({...current('loadBalancerService'), baseType: 'service'})); + run(() => f.response.resolve({status: 201, body: {...initial('loadBalancerService'), baseType: 'service'}})); + assert.strictEqual(await saving, draft); + assert.strictEqual(draft.get('state'), 'inactive'); + assert.strictEqual(f.store.getById('loadBalancerService', 'Opaque-ID'), draft); + assert.strictEqual(f.store.getById('service', 'Opaque-ID'), draft); + assert.strictEqual(f.store.all('loadBalancerService').get('length'), 1); + assert.strictEqual(f.store.all('service').get('length'), 1); + } finally { f.destroy(); } + }); + + test('cached create adoption does not run stale mangleIn or nested resource imports', function(assert) { + const f = fixture(); + try { + const schema = f.store.getById('schema', 'volume'); + schema.set('resourceFields', { nested: {type: 'service'} }); + schema.notifyPropertyChange('typeifyFields'); + const nested = run(() => f.store._typeify({...current('service', 'Nested-ID'), state: 'active'})); + const live = run(() => f.store._typeify({...current(), nested})); + let conversionCalls = 0; + const createRecord = f.store.createRecord; + f.store.createRecord = (...args) => { conversionCalls++; return createRecord(...args); }; + const options = {method: 'POST', createIdentity: {type: 'volume', + generation: f.store.get('generation'), baseUrl: f.store.get('baseUrl')}}; + const response = f.store._requestSuccess({status: 201, + body: {...initial(), nested: {...initial('service', 'Nested-ID'), state: 'creating'}}}, options); + assert.strictEqual(response, live); + assert.strictEqual(conversionCalls, 0, 'neither mangleIn nor nested typeify is invoked'); + assert.strictEqual(nested.get('state'), 'active'); + assert.strictEqual(live.get('nested'), nested); + } finally { f.destroy(); } + }); + + test('opaque case-sensitive IDs and exact concrete types do not borrow another canonical model', function(assert) { + const f = fixture(); + try { + const other = f.store._typeify(current('volume', 'opaque-id')); + const options = {method: 'POST', createIdentity: {type: 'volume', + generation: f.store.get('generation'), baseUrl: f.store.get('baseUrl')}}; + const response = f.store._requestSuccess({status: 201, body: initial()}, options); + assert.notStrictEqual(response, other); + assert.strictEqual(response.get('id'), 'Opaque-ID'); + assert.strictEqual(response.get('state'), 'registering'); + const concrete = f.store._typeify({...current('loadBalancerService', 'Sub-ID'), baseType: 'service'}); + const base = f.store._requestSuccess({status: 201, body: initial('service', 'Sub-ID')}, + {...options, createIdentity: {...options.createIdentity, type: 'service'}}); + assert.strictEqual(base.get('type'), 'service', 'base alias goes through the normal import'); + assert.strictEqual(concrete.get('state'), 'registering', 'the new rule did not adopt the different concrete type'); + } finally { f.destroy(); } + }); + + test('another project store, reset generation and changed API base cannot use create adoption', function(assert) { + const f = fixture(); + const other = fixture('1a-other'); + try { + const foreign = other.store._typeify(current()); + const marker = {type: 'volume', generation: f.store.get('generation'), baseUrl: f.store.get('baseUrl')}; + const response = f.store._requestSuccess({status: 201, body: initial()}, {method: 'POST', createIdentity: marker}); + assert.notStrictEqual(response, foreign); + assert.strictEqual(foreign.get('state'), 'inactive'); + run(() => f.store.reset()); + const afterReset = f.store._typeify(current()); + f.store._requestSuccess({status: 201, body: initial()}, {method: 'POST', createIdentity: marker}); + assert.strictEqual(afterReset.get('state'), 'registering', 'old generation does not opt into the new rule'); + const beforeBaseChange = {type: 'volume', generation: f.store.get('generation'), baseUrl: f.store.get('baseUrl')}; + f.store.set('baseUrl', '/v2-beta/projects/1a-changed'); + afterReset.set('state', 'inactive'); + f.store._requestSuccess({status: 201, body: initial()}, {method: 'POST', createIdentity: beforeBaseChange}); + assert.strictEqual(afterReset.get('state'), 'registering', 'different base preserves prior import behavior'); + } finally { f.destroy(); other.destroy(); } + }); + + test('GET, PUT, action POST and non-201 responses preserve normal imports', function(assert) { + const f = fixture(); + try { + for ( const [method, status, marked] of [['GET', 201, true], ['PUT', 201, true], + ['POST', 200, true], ['POST', 201, false]] ) { + const live = f.store._typeify(current()); + const options = {method}; + if ( marked ) { options.createIdentity = {type: 'volume', + generation: f.store.get('generation'), baseUrl: f.store.get('baseUrl')}; } + const response = f.store._requestSuccess({status, body: initial()}, options); + assert.strictEqual(response, live); + assert.strictEqual(response.get('state'), 'registering', `${method}/${status}/${marked} unchanged`); + } + } finally { f.destroy(); } + }); + + test('204 and errors keep their HTTP semantics without importing a model', async function(assert) { + const f = fixture(); + try { + const options = {method: 'POST', createIdentity: {type: 'volume', + generation: f.store.get('generation'), baseUrl: f.store.get('baseUrl')}}; + const live = f.store._typeify(current()); + assert.strictEqual(f.store._requestSuccess({status: 204}, options), undefined); + assert.strictEqual(options.responseStatus, 204); + assert.strictEqual(live.get('state'), 'inactive'); + f.store.rawRequest = () => Promise.reject({status: 403, body: {type: 'error', status: 403, message: 'Forbidden'}}); + try { await f.store.request({...options, url: 'volume'}); assert.ok(false); } + catch (error) { assert.strictEqual(error.get('status'), 403); } + assert.strictEqual(live.get('state'), 'inactive'); + } finally { f.destroy(); } + }); + + test('reusing save options cannot carry a create marker into an existing record save', async function(assert) { + const f = fixture(); + try { + const record = f.store._typeify({...current(), links: {self: `${f.store.baseUrl}/volumes/Opaque-ID`}}); + const options = {createIdentity: {type: 'volume', generation: f.store.get('generation'), baseUrl: f.store.baseUrl}}; + f.store.rawRequest = request => { f.requests.push(request); return resolve({status: 200, body: initial()}); }; + await run(() => record.save(options)); + assert.strictEqual(f.requests[0].method, 'PUT'); + assert.notOk(Object.hasOwn(f.requests[0], 'createIdentity')); + assert.strictEqual(record.get('state'), 'registering'); + } finally { f.destroy(); } + }); + + test('action POST cannot reuse an old create marker even when the action returns 201', async function(assert) { + const f = fixture(); + try { + const record = f.store._typeify({...current(), actionLinks: {reconcile: '/actions/reconcile'}}); + const options = {createIdentity: {type: 'volume', generation: f.store.get('generation'), baseUrl: f.store.baseUrl}}; + f.store.rawRequest = request => { f.requests.push(request); return resolve({status: 201, body: initial()}); }; + assert.strictEqual(await run(() => record.doAction('reconcile', null, options)), record); + assert.strictEqual(f.requests[0].method, 'POST'); + assert.notOk(Object.hasOwn(f.requests[0], 'createIdentity')); + assert.strictEqual(record.get('state'), 'registering', 'action response still imports normally'); + } finally { f.destroy(); } + }); +}); diff --git a/vendor/ember-api-store-compat/UPSTREAM.md b/vendor/ember-api-store-compat/UPSTREAM.md index 8c60925d85..35113d99fb 100644 --- a/vendor/ember-api-store-compat/UPSTREAM.md +++ b/vendor/ember-api-store-compat/UPSTREAM.md @@ -28,3 +28,13 @@ stores remain separate. Resource names, server schemas, authorization and reques methods are unchanged. This compatibility packaging preserves upstream authorship. PastureStack does not claim authorship of the imported runtime source. + +Compatibility revision 5 prevents an initial HTTP 201 create snapshot from +overwriting a newer subscribe model for the same generated resource ID. Only +an ID-less `Type.save` POST opts into canonical model adoption, and only within +its captured store generation and API base URL. Opaque resource IDs and concrete +types must match; cached nested relationships are not re-imported from the older +body. Save completion, base-type aliases, HTTP metadata and errors retain their +existing contracts. GET, PUT, actions, non-201 responses and uncached creates +continue through the original import path. This does not order resource states +or event timestamps, grant permissions, change API responses, or add requests. diff --git a/vendor/ember-api-store-compat/addon/mixins/type.js b/vendor/ember-api-store-compat/addon/mixins/type.js index b552a2611a..e240df0752 100644 --- a/vendor/ember-api-store-compat/addon/mixins/type.js +++ b/vendor/ember-api-store-compat/addon/mixins/type.js @@ -130,6 +130,7 @@ var Type = Mixin.create(Serializable,{ } opt = opt || {}; + delete opt.createIdentity; opt.method = 'POST'; opt.url = opt.url || url; if ( data ) { @@ -144,6 +145,7 @@ var Type = Mixin.create(Serializable,{ var self = this; var store = get(this, 'store'); opt = opt || {}; + delete opt.createIdentity; var id = get(this, 'id'); var type = normalizeType(get(this, 'type')); @@ -159,6 +161,15 @@ var Type = Mixin.create(Serializable,{ opt.method = opt.method || 'POST'; opt.url = opt.url || type; + // A generated ID may arrive over subscribe before its original 201. + // Bind this create-only adoption to the store that started the request. + if ( opt.method === 'POST' ) { + opt.createIdentity = { + type, + generation: get(store, 'generation'), + baseUrl: get(store, 'baseUrl'), + }; + } } if ( opt.qp ) { diff --git a/vendor/ember-api-store-compat/addon/services/store.js b/vendor/ember-api-store-compat/addon/services/store.js index 915df6b8f5..8f3700eecd 100644 --- a/vendor/ember-api-store-compat/addon/services/store.js +++ b/vendor/ember-api-store-compat/addon/services/store.js @@ -516,7 +516,25 @@ var Store = Service.extend({ } if ( xhr.body && typeof xhr.body === 'object' ) { - let response = this._typeify(xhr.body); + let response; + const creation = opt.createIdentity; + // Only a new-record save can use this rule. Its 201 is the initial + // snapshot; the same generated ID already in this store has arrived + // through subscribe while that response was in flight. Do not import + // its stale fields (including nested resources) over the live model. + if ( xhr.status === 201 && opt.method === 'POST' && creation && + creation.generation === get(this, 'generation') && + creation.baseUrl === get(this, 'baseUrl') && + typeof xhr.body.id === 'string' && xhr.body.id.length > 0 && + normalizeType(xhr.body.type, this) === creation.type ) { + const cached = this.getById(creation.type, xhr.body.id); + if ( cached && cached.get('id') === xhr.body.id && + normalizeType(cached.get('type'), this) === creation.type && + get(cached, 'store') === this && this.hasRecord(cached) ) { + response = cached; + } + } + response = response || this._typeify(xhr.body); delete xhr.body; Object.defineProperty(response, 'xhr', {value: xhr, configurable: true}); diff --git a/vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz b/vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz new file mode 100644 index 0000000000..02629c64c0 Binary files /dev/null and b/vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz differ diff --git a/vendor/ember-api-store-compat/package.json b/vendor/ember-api-store-compat/package.json index d7a00e7fc4..b31613d17f 100644 --- a/vendor/ember-api-store-compat/package.json +++ b/vendor/ember-api-store-compat/package.json @@ -32,7 +32,7 @@ "node": ">=24" }, "pasturestackCompatibility": { - "revision": 4, + "revision": 5, "upstreamPackage": "ember-api-store", "upstreamVersion": "2.8.5", "upstreamIntegrity": "sha512-YvnBZfdNGG7hB25hecEENHObXNN+186Bbkd1wAIL7qtRXqboQsQxTU05xxP7axgW6TPYfUYMxZ+GgbHgD14g2A=="