From 1ad27e5b9bd6b0696dd3389a26da33d6e41f547d Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 11:13:38 +0800 Subject: [PATCH 1/9] Fix shared create response ordering without overwriting subscribe models --- COMPATIBILITY.md | 14 ++ README.md | 14 ++ ...ass-replacement.node24-ignore-scripts.json | 10 +- docs/releases/web-console-1.6.171.md | 54 +++++ package-lock.json | 10 +- package.json | 4 +- scripts/check-modernization-blockers | 4 +- scripts/check-ui-console-workspace | 2 +- scripts/check-ui-critical-high-dependencies | 4 +- .../check-ui-ember-api-store-fetch-upgrade | 40 +++- scripts/node24-lock-smoke.js | 38 ++- .../vendor/api-store-create-order-test.js | 225 ++++++++++++++++++ vendor/ember-api-store-compat/UPSTREAM.md | 10 + .../addon/mixins/type.js | 11 + .../addon/services/store.js | 20 +- .../ember-api-store-2.8.5-pasturestack.5.tgz | Bin 0 -> 23020 bytes vendor/ember-api-store-compat/package.json | 2 +- 17 files changed, 435 insertions(+), 27 deletions(-) create mode 100644 docs/releases/web-console-1.6.171.md create mode 100644 tests/unit/vendor/api-store-create-order-test.js create mode 100644 vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.5.tgz diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 27ffc44e07..403711b3d4 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -4,6 +4,20 @@ 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). + 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..7afdf0e348 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,20 @@ 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). + 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..a411dc2abd --- /dev/null +++ b/docs/releases/web-console-1.6.171.md @@ -0,0 +1,54 @@ +# 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. + +## 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/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-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..e17e99f4f7 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -53,7 +53,7 @@ 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" +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 +66,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')}") 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/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/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 0000000000000000000000000000000000000000..02629c64c007fcd277289a99d572626d6a4dc1c5 GIT binary patch literal 23020 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 literal 0 HcmV?d00001 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==" From 159356fd3b9f5fbac56c4800e2a93c36bb2eb800 Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 11:29:05 +0800 Subject: [PATCH 2/9] ci: gate exact upstream-pending braces build advisory without weakening audit --- COMPATIBILITY.md | 8 + README.md | 9 + docs/releases/web-console-1.6.171.md | 16 ++ docs/security/npm-vendor-pending.json | 39 ++++ scripts/check-ui-critical-high-dependencies | 10 +- scripts/check-ui-npm-audit.js | 246 ++++++++++++++++++++ scripts/ci | 3 +- scripts/test-ui-npm-audit.js | 201 ++++++++++++++++ 8 files changed, 528 insertions(+), 4 deletions(-) create mode 100644 docs/security/npm-vendor-pending.json create mode 100644 scripts/check-ui-npm-audit.js create mode 100644 scripts/test-ui-npm-audit.js diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 403711b3d4..8e401dc9be 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -18,6 +18,14 @@ 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 7afdf0e348..6a6aa2a6c1 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,15 @@ 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/releases/web-console-1.6.171.md b/docs/releases/web-console-1.6.171.md index a411dc2abd..ca3995d78c 100644 --- a/docs/releases/web-console-1.6.171.md +++ b/docs/releases/web-console-1.6.171.md @@ -46,6 +46,22 @@ 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. 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/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index e17e99f4f7..51ff79c629 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -51,8 +51,12 @@ 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") +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() @@ -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-npm-audit.js b/scripts/check-ui-npm-audit.js new file mode 100644 index 0000000000..2b15703183 --- /dev/null +++ b/scripts/check-ui-npm-audit.js @@ -0,0 +1,246 @@ +'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) { + const sources = { 'ember-cli-build.js': fs.readFileSync(path.join(repoRoot, 'ember-cli-build.js'), 'utf8') }; + function walk(relative) { + for (const entry of fs.readdirSync(path.join(repoRoot, relative), { withFileTypes: true })) { + const file = relative + '/' + entry.name; + need(!entry.isSymbolicLink(), 'BROWSER_SOURCE_SYMLINK_UNREVIEWED'); + if (entry.isDirectory()) walk(file); + else if (entry.isFile() && entry.name.endsWith('.js')) sources[file] = fs.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]])); + 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'); + need(totals.critical === 0, 'CRITICAL_VULNERABILITY'); + 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 runAudit(repoRoot, runner = spawnSync) { + 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' }; + } + let command = 'npm'; + let args = ['audit', '--audit-level=high', '--json']; + if (process.platform === 'win32') { + const candidates = [path.join(process.env.APPDATA || '', 'npm/node_modules/npm/bin/npm-cli.js'), + path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js')]; + const cli = candidates.find(p => { + try { return fs.existsSync(p) && JSON.parse(fs.readFileSync(path.resolve(p, '../../package.json'), 'utf8')).version === '12.0.2'; } + catch (_) { return false; } + }); + if (!cli) return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_CLI_UNAVAILABLE' }; + command = process.execPath; + args = [cli, ...args]; + } + let result; + try { result = runner(command, args, { cwd: repoRoot, encoding: 'utf8', shell: false, timeout: 120000, maxBuffer: 8 * 1024 * 1024 }); } + 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, 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/test-ui-npm-audit.js b/scripts/test-ui-npm-audit.js new file mode 100644 index 0000000000..f19e59d779 --- /dev/null +++ b/scripts/test-ui-npm-audit.js @@ -0,0 +1,201 @@ +'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, 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', () => { + let calls = 0; + const result = runAudit(root, (command, args, options) => { + calls++; + assert.deepEqual(args.slice(-3), ['audit', '--audit-level=high', '--json']); + assert.equal(options.shell, false); + assert.equal(options.cwd, root); + assert.equal(args.includes('fix'), false); + return { status: 1, stdout: JSON.stringify(report()), stderr: 'private stderr marker' }; + }); + assert.equal(calls, 1); 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 returned; }); + assert.equal(calls, 1); 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); assert.equal(result.failureCode, 'NPM_PROCESS_ERROR'); +}); +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' }); +}); From b9e3f309b4950e7d9dc12349f25d25cd0478fe3d Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 11:32:33 +0800 Subject: [PATCH 3/9] ci: prioritize Critical rejection and preserve bounded preflight diagnostics --- scripts/check-ui-npm-audit.js | 4 +++- scripts/test-ui-npm-audit.js | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/check-ui-npm-audit.js b/scripts/check-ui-npm-audit.js index 2b15703183..40eef20885 100644 --- a/scripts/check-ui-npm-audit.js +++ b/scripts/check-ui-npm-audit.js @@ -129,6 +129,9 @@ function evaluateAudit({ audit, lock, pending, now = new Date(), npmExitCode }) [...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; @@ -161,7 +164,6 @@ function evaluateAudit({ audit, lock, pending, now = new Date(), npmExitCode }) } 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'); - need(totals.critical === 0, 'CRITICAL_VULNERABILITY'); const visiting = new Set(); const verified = new Set(); function knownClosure(name) { diff --git a/scripts/test-ui-npm-audit.js b/scripts/test-ui-npm-audit.js index f19e59d779..d3e2bebbee 100644 --- a/scripts/test-ui-npm-audit.js +++ b/scripts/test-ui-npm-audit.js @@ -168,7 +168,7 @@ test('main runner truly selects high JSON audit once, never install/fix, and emi assert.equal(args.includes('fix'), false); return { status: 1, stdout: JSON.stringify(report()), stderr: 'private stderr marker' }; }); - assert.equal(calls, 1); assert.equal(result.ok, true); + assert.equal(calls, 1, 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', () => { @@ -177,12 +177,12 @@ test('runner malformed JSON, network errors and thrown process errors are finite { status: null, error: new Error('secret network failure'), stdout: '' }]) { let calls = 0; const result = runAudit(root, () => { calls++; return returned; }); - assert.equal(calls, 1); assert.equal(result.ok, false); + assert.equal(calls, 1, 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); assert.equal(result.failureCode, 'NPM_PROCESS_ERROR'); + assert.equal(calls, 1, JSON.stringify(result)); assert.equal(result.failureCode, 'NPM_PROCESS_ERROR'); }); 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');", From 74cc7f79769fbbc1d05c9b33d1444266664ff98d Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 11:34:09 +0800 Subject: [PATCH 4/9] ci: exclude cold-installed addon dependencies from owned-source import scan --- scripts/check-ui-npm-audit.js | 18 +++++++++++++----- scripts/test-ui-npm-audit.js | 28 +++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/scripts/check-ui-npm-audit.js b/scripts/check-ui-npm-audit.js index 40eef20885..abc4a04404 100644 --- a/scripts/check-ui-npm-audit.js +++ b/scripts/check-ui-npm-audit.js @@ -103,14 +103,22 @@ function validateReviewedImports(sources, pending) { // requires the separate packaged-browser inventory; never emit notAffected. } -function readReviewedSources(repoRoot) { - const sources = { 'ember-cli-build.js': fs.readFileSync(path.join(repoRoot, 'ember-cli-build.js'), 'utf8') }; +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 fs.readdirSync(path.join(repoRoot, relative), { withFileTypes: true })) { + 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] = fs.readFileSync(path.join(repoRoot, file), 'utf8'); + 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); @@ -240,7 +248,7 @@ function runAudit(repoRoot, runner = spawnSync) { return evaluateAudit({ audit, lock, pending, npmExitCode: result.status }); } -module.exports = { evaluateAudit, validatePending, validateReviewedImports, runAudit }; +module.exports = { evaluateAudit, validatePending, validateReviewedImports, readReviewedSources, runAudit }; if (require.main === module) { const result = runAudit(path.resolve(__dirname, '..')); console.log(JSON.stringify(result)); diff --git a/scripts/test-ui-npm-audit.js b/scripts/test-ui-npm-audit.js index d3e2bebbee..df02173642 100644 --- a/scripts/test-ui-npm-audit.js +++ b/scripts/test-ui-npm-audit.js @@ -5,7 +5,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { test } = require('node:test'); -const { evaluateAudit, validateReviewedImports, runAudit } = require('./check-ui-npm-audit'); +const { evaluateAudit, validateReviewedImports, readReviewedSources, 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')); @@ -199,3 +199,29 @@ test('reviewed runtime import boundary refuses every pending consumer while exac 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' }); +}); From 7a14f3a95489ad32688a2729f9b2068f89d14208 Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 11:39:44 +0800 Subject: [PATCH 5/9] fix(ci): include create-order regression source in isolated lock smoke --- scripts/check-node24-lock-baseline | 2 ++ 1 file changed, 2 insertions(+) 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" From fc37f5af9320e492bec7e7244cd62144908b720e Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 11:54:01 +0800 Subject: [PATCH 6/9] fix: use pinned npm on PATH without environment path or cwd probing --- scripts/check-ui-npm-audit.js | 51 ++++++++++++++------ scripts/test-ui-npm-audit.js | 91 +++++++++++++++++++++++++++++++---- 2 files changed, 117 insertions(+), 25 deletions(-) diff --git a/scripts/check-ui-npm-audit.js b/scripts/check-ui-npm-audit.js index abc4a04404..4d29313e07 100644 --- a/scripts/check-ui-npm-audit.js +++ b/scripts/check-ui-npm-audit.js @@ -213,7 +213,30 @@ function evaluateAudit({ audit, lock, pending, now = new Date(), npmExitCode }) } } -function runAudit(repoRoot, runner = spawnSync) { +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')); @@ -224,21 +247,17 @@ function runAudit(repoRoot, runner = spawnSync) { return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: /^[A-Z0-9_]+$/.test(error.message) ? error.message : 'PREFLIGHT_INPUT_ERROR' }; } - let command = 'npm'; - let args = ['audit', '--audit-level=high', '--json']; - if (process.platform === 'win32') { - const candidates = [path.join(process.env.APPDATA || '', 'npm/node_modules/npm/bin/npm-cli.js'), - path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js')]; - const cli = candidates.find(p => { - try { return fs.existsSync(p) && JSON.parse(fs.readFileSync(path.resolve(p, '../../package.json'), 'utf8')).version === '12.0.2'; } - catch (_) { return false; } - }); - if (!cli) return { ok: false, outcome: 'FAIL_CLOSED', totals: null, failureCode: 'NPM_CLI_UNAVAILABLE' }; - command = process.execPath; - args = [cli, ...args]; - } + 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 = runner(command, args, { cwd: repoRoot, encoding: 'utf8', shell: false, timeout: 120000, maxBuffer: 8 * 1024 * 1024 }); } + 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' }; @@ -248,7 +267,7 @@ function runAudit(repoRoot, runner = spawnSync) { return evaluateAudit({ audit, lock, pending, npmExitCode: result.status }); } -module.exports = { evaluateAudit, validatePending, validateReviewedImports, readReviewedSources, runAudit }; +module.exports = { evaluateAudit, validatePending, validateReviewedImports, readReviewedSources, npmInvocation, runAudit }; if (require.main === module) { const result = runAudit(path.resolve(__dirname, '..')); console.log(JSON.stringify(result)); diff --git a/scripts/test-ui-npm-audit.js b/scripts/test-ui-npm-audit.js index df02173642..868ace7302 100644 --- a/scripts/test-ui-npm-audit.js +++ b/scripts/test-ui-npm-audit.js @@ -5,7 +5,7 @@ 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, runAudit } = require('./check-ui-npm-audit'); +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')); @@ -159,30 +159,103 @@ test('npm non-audit failures and inconsistent exit status never become allowed p 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', () => { - let calls = 0; - const result = runAudit(root, (command, args, options) => { + for (const platform of ['linux', 'win32']) { + let calls = 0; + const result = runAudit(root, (command, args, options) => { calls++; - assert.deepEqual(args.slice(-3), ['audit', '--audit-level=high', '--json']); + 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' }; - }); - assert.equal(calls, 1, JSON.stringify(result)); assert.equal(result.ok, true); - assert.equal(JSON.stringify(result).includes('private'), false); + }, 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 returned; }); - assert.equal(calls, 1, JSON.stringify(result)); assert.equal(result.ok, false); + 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');", From 6b9ba4c6ab6deaea3d7ae8b7f68eb8f5198c1678 Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:15:17 +0800 Subject: [PATCH 7/9] Fix request-owned API key first delivery without caching secrets --- COMPATIBILITY.md | 14 ++ README.md | 13 ++ app/components/edit-apikey/component.js | 3 +- app/mixins/new-or-edit.js | 35 +++++ ...ass-replacement.node24-ignore-scripts.json | 10 +- docs/releases/web-console-1.6.172.md | 65 ++++++++ package-lock.json | 10 +- package.json | 4 +- scripts/check-modernization-blockers | 4 +- scripts/check-ui-console-workspace | 2 +- scripts/check-ui-critical-high-dependencies | 4 +- .../check-ui-ember-api-store-fetch-upgrade | 20 ++- scripts/node24-lock-smoke.js | 13 +- tests/unit/mixins/new-or-edit-test.js | 74 +++++++++ .../vendor/api-store-create-order-test.js | 148 +++++++++++++++++- vendor/ember-api-store-compat/UPSTREAM.md | 10 ++ .../addon/mixins/type.js | 12 +- .../addon/services/store.js | 36 +++++ .../addon/utils/create-only-delivery.js | 36 +++++ .../ember-api-store-2.8.5-pasturestack.6.tgz | Bin 0 -> 24320 bytes vendor/ember-api-store-compat/package.json | 2 +- 21 files changed, 475 insertions(+), 40 deletions(-) create mode 100644 docs/releases/web-console-1.6.172.md create mode 100644 vendor/ember-api-store-compat/addon/utils/create-only-delivery.js create mode 100644 vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 8e401dc9be..b510790d89 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -4,6 +4,20 @@ 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.172` adds opt-in first delivery of fields whose actual Schema +declares `readOnCreateOnly: true`. Only `edit-apikey` enables it. A nonenumerable +request-private callback delivers the successful create values once to a +detached clone, not to serialized metadata or canonical cache. Matching Store, +generation, API base, opaque generated ID, concrete type and owner are required; +newer subscribe state and nested-resource adoption are preserved. Other +NewOrEdit hook arguments/results and consumers retain their previous contracts. +The save owner clears its own pending delivery on success and failure; rejected +duplicates cannot clear another save's lock or values. Compatibility revision 6 +is a new archive with the same dependency graph. Source/package checks pass; +local Chrome tests have not run because of incomplete shared dependencies. +Official tests, publication and packaged native acceptance are pending, not +full-matrix PASS. See the [release note](docs/releases/web-console-1.6.172.md). + 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 diff --git a/README.md b/README.md index 6a6aa2a6c1..acb3b1b926 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,19 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status +Candidate `1.6.172` preserves API-key create-only first delivery when a redacted +subscribe model arrives before POST/201. Only the API-key editor opts into a +request-private, Schema-bound delivery to its detached clone; newer canonical +state and nested resources remain intact, and the canonical Store does not need +to retain the secret. Compatibility revision 6 replaces revision 5 without +changing dependency versions or the graph. Source/package checks pass; new +installed-Store and save-owner regressions have been added, including personal +and project stores with 100 deterministic barriers each. Local Chrome tests +have not run because the local shared dependency layout is incomplete. Official +tests, publication and packaged native acceptance remain pending. Historical +HOLDs remain HOLD; the full matrix is INCOMPLETE. See the +[release note](docs/releases/web-console-1.6.172.md). + 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 diff --git a/app/components/edit-apikey/component.js b/app/components/edit-apikey/component.js index 1ae75cebf9..2f6c63730f 100644 --- a/app/components/edit-apikey/component.js +++ b/app/components/edit-apikey/component.js @@ -8,6 +8,7 @@ export default ModalBase.extend(NewOrEdit, { model: null, clone: null, justCreated: false, + createOnlyDelivery: true, didReceiveAttrs() { this.set('clone', this.get('originalModel').clone()); @@ -52,7 +53,7 @@ export default ModalBase.extend(NewOrEdit, { { this.setProperties({ justCreated: true, - clone: neu.clone() + clone: this.cloneForCreateDelivery(neu) }); } }, diff --git a/app/mixins/new-or-edit.js b/app/mixins/new-or-edit.js index a219264423..f76214fd15 100644 --- a/app/mixins/new-or-edit.js +++ b/app/mixins/new-or-edit.js @@ -3,6 +3,7 @@ import { alias } from '@ember/object/computed'; import { service } from '@ember/service'; import Mixin from '@ember/object/mixin'; import Resource from 'ember-api-store/models/resource'; +import { bindCreateOnlyDelivery, cloneCreateOnlyDelivery, takeCreateOnlyDelivery } from 'ember-api-store/utils/create-only-delivery'; import Errors from 'ui/utils/errors'; export default Mixin.create({ @@ -11,6 +12,7 @@ export default Mixin.create({ errors: null, saving: false, editing: true, + createOnlyDelivery: false, primaryResource: alias('model'), originalPrimaryResource: alias('originalModel'), @@ -106,6 +108,14 @@ export default Mixin.create({ let finalizerError = null; if ( this._saveOwner === owner ) { + if ( this._createOnlyRequest && this._createOnlyRequest.owner === owner ) { + takeCreateOnlyDelivery(this._createOnlyRequest.options); + this._createOnlyRequest = null; + } + if ( this._createOnlyDelivery && this._createOnlyDelivery.owner === owner ) { + this._createOnlyDelivery.data.fields = null; + this._createOnlyDelivery = null; + } this._saveOwner = null; // A hook that turned saving on and then threw still owns that // state, but a submission which found a pre-existing saving=true @@ -199,11 +209,36 @@ export default Mixin.create({ }, doSave: function(opt) { + const owner = this._saveOwner; + if ( owner && this.get('createOnlyDelivery') ) { + opt = opt || {}; + Object.defineProperty(this, '_createOnlyRequest', { + value: { owner, options: opt }, writable: true, configurable: true, + }); + bindCreateOnlyDelivery(opt, (data) => { + if ( this._saveOwner === owner && !this.isDestroyed && !this.isDestroying ) { + Object.defineProperty(this, '_createOnlyDelivery', { + value: { owner, data }, writable: true, configurable: true, + }); + } else { + data.fields = null; + } + }); + } return this.get('primaryResource').save(opt).then((newData) => { return this.mergeResult(newData); }); }, + cloneForCreateDelivery(resource) { + const pending = this._createOnlyDelivery; + if ( !pending || pending.owner !== this._saveOwner ) { + return resource.clone(); + } + this._createOnlyDelivery = null; + return cloneCreateOnlyDelivery(resource, pending.data); + }, + mergeResult: function(newData) { var original = this.get('originalPrimaryResource'); if ( original ) 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 c5d74e292d..1d8631e649 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.171", + "version": "1.6.172", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.171", + "version": "1.6.172", "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.5.tgz", + "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.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.5.tgz", - "integrity": "sha512-m+IpOrSUqogl3EP8DqefpDuO/9leZ4Bycge7MLwqYASOz75V/J6ay1bFGaOWd2ckaohymODeOlkVzyVzmWLupw==", + "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz", + "integrity": "sha512-ojkclvGZq8iObzwSkOBtZxkt9IwZ0GJvmi8CMYFocBwol6YQh9Q4D6g4t6/o/3mNDYmDkULPgdXBVt81xm5BqA==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/docs/releases/web-console-1.6.172.md b/docs/releases/web-console-1.6.172.md new file mode 100644 index 0000000000..f64e5d5f32 --- /dev/null +++ b/docs/releases/web-console-1.6.172.md @@ -0,0 +1,65 @@ +# Web Console 1.6.172 + +Candidate first-delivery repair. Official tests, publication and packaged QA +are separate pending gates; historical failed receipts remain HOLD. + +## Root cause and contract + +Revision 5 correctly adopts a newer cached subscribe model instead of importing +an older POST/201 snapshot. For an API key, subscribe can already contain +`secretValue: null`; discarding the whole 201 also loses its only delivery of the +new secret. The API-key editor therefore cannot show its expected detached +first-delivery clone. Requiring canonical Store secrets to survive later +redacted subscribe updates is neither the fix nor the acceptance contract. + +Engine 333 source `0d94f7d879d314235e582a7f4062914a27b82709` maps auth-overlay +permission `o` to `FieldImpl.readOnCreateOnly` in `AuthOverlayPostProcessor`. +`Field.isReadOnCreateOnly` and its JavaBean implementation export the actual +Schema resource-field property `readOnCreateOnly`. This repair uses that exact +property; it does not invent a schema flag or merge all create-response fields. + +## Minimal change + +An ID-less create request captures only Schema-marked field names in its +nonenumerable internal identity metadata. An opt-in request-private Symbol +callback transports only those successful POST/201 values. API-key editing is +the sole NewOrEdit opt-in. Values are withheld from the canonical import and +consumed once into the editor's detached clone, whose visible/copy value remains +independent of later subscribe redaction. Normal cached state and nested models +are not re-imported from the older response. + +Delivery requires the same Store, generation, API base, exact generated ID, +concrete type and account binding. The existing save owner clears only its own +pending callback and delivery, including failed hooks and synchronous completion +exceptions. Duplicate submissions neither resend create nor clear the owner's +lock or values. Existing hook arguments/results, non-opted-in consumers, backend +permissions, API payloads and request counts remain unchanged. + +API-store compatibility revision 6 is a new archive. Earlier archives and +upstream license text are retained; dependency versions and graph are unchanged. +The source/package checker accepts Windows license line endings while requiring +the unchanged upstream content and exact source/archive equality. + +## Verification boundary + +The ten prior installed-Store ordering regressions remain. Added cases cover +actual API-key doneSaving delivery, redacted subscribe before 201, later +redaction, personal/project Stores (100 deterministic deferred HTTP barriers +each, without sleeps), uncached one-shot delivery, private metadata, store/ +generation/base/type/account mismatch, and synchronous delivery exceptions. +NewOrEdit tests cover delivery cleanup across success, request rejection, +synchronous doneSaving/completion exceptions and rejected duplicate submissions; +ordinary consumers keep their prior options and return value. + +Source/package checks pass. The local Chrome suite has not started because the +local junction-based dependency layout is incomplete; it is not reported as a +test PASS. Exact-source official tests, immutable publication and packaged native +first-delivery acceptance remain pending. Historical HOLDs and the complete +permission/resource/locale matrix are not promoted. + +## Upgrade and rollback + +Use only a separately published Server package containing this exact component. +Retain existing settings, persistent volumes and previous immutable artifacts. +No database migration or backend change is required. This candidate does not +authorize deployment, live retries or a change to authentication settings. diff --git a/package-lock.json b/package-lock.json index c5d74e292d..1d8631e649 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.171", + "version": "1.6.172", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.171", + "version": "1.6.172", "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.5.tgz", + "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.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.5.tgz", - "integrity": "sha512-m+IpOrSUqogl3EP8DqefpDuO/9leZ4Bycge7MLwqYASOz75V/J6ay1bFGaOWd2ckaohymODeOlkVzyVzmWLupw==", + "resolved": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz", + "integrity": "sha512-ojkclvGZq8iObzwSkOBtZxkt9IwZ0GJvmi8CMYFocBwol6YQh9Q4D6g4t6/o/3mNDYmDkULPgdXBVt81xm5BqA==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 28758ebccb..22851ccf29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.171", + "version": "1.6.172", "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.5.tgz", + "ember-api-store": "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.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 d78bc35a33..40caa864f2 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.171" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.171" +if [[ "$version" != "1.6.172" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.172" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index 60fc4ade01..35d05d92c9 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.171 browser-session broker-broadcast + 1.6.172 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 51ff79c629..0b2c02ef9b 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -57,7 +57,7 @@ for gate in ("node ./scripts/test-ui-npm-audit.js", "node ./scripts/check-ui-npm 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" +api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz" lock_bytes = lock_path.read_bytes() baseline_bytes = baseline_path.read_bytes() if lock_bytes != baseline_bytes: @@ -70,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.171": +if package.get("version") != "1.6.172": 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')}") diff --git a/scripts/check-ui-ember-api-store-fetch-upgrade b/scripts/check-ui-ember-api-store-fetch-upgrade index de21614f27..84d18eaa17 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.5.tgz" +api_store_compat_spec = "file:vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.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.5.tgz" +api_store_archive = api_store_compat_dir / "ember-api-store-2.8.5-pasturestack.6.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() != "90da9ebdc36a8069629086d011e799691ace8f88c13d9c9df1333c77015a2ab8": +if hashlib.sha256(api_store_archive.read_bytes()).hexdigest() != "58079a9d1cc9539d89999f9fb3ac9f89464a1d79f45bb1c8ada18b8eda545bb6": fail("reviewed ember-api-store compatibility archive hash changed") -if hashlib.sha256(api_store_license_path.read_bytes()).hexdigest() != "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594": +if hashlib.sha256(api_store_license_path.read_bytes().replace(b"\r\n", b"\n")).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") != 5: +if api_store_package.get("pasturestackCompatibility", {}).get("revision") != 6: 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", "addon/mixins/type.js"]: + for relative in ["package.json", "LICENSE", "UPSTREAM.md", "addon/services/store.js", "addon/mixins/type.js", "addon/utils/create-only-delivery.js"]: archived = archive.extractfile(f"package/{relative}") source = api_store_compat_dir / relative if archived is None or archived.read() != source.read_bytes(): @@ -154,7 +154,7 @@ action_dispatch = type_runtime.split(" doAction: function(name, data, opt) {", 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')"]: +for marker in ["delete opt.createIdentity;", "if ( opt.method === 'POST' )", "Object.defineProperty(opt, 'createIdentity'", "generation: get(store, 'generation')", "baseUrl: get(store, 'baseUrl')", "fields[key].readOnCreateOnly === true"]: 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);"]: @@ -172,6 +172,10 @@ for marker in [ "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", + "apiKey first delivery survives redacted subscribe before 201 and later redaction in personal and project stores, 100 deterministic barriers each", + "uncached API-key create-only delivery is one-shot and schema-bound, with private request metadata", + "create-only delivery rejects changed store, generation, base, concrete type and owner without importing its secret", + "a synchronous first-delivery callback exception is consumed once and cannot replay create", ]: if marker not in create_order_tests: fail(f"API-store create response order regression missing: {marker}") @@ -454,7 +458,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=5 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=6 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/node24-lock-smoke.js b/scripts/node24-lock-smoke.js index 9f85ad89c4..5b3f848e14 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 !== 5) { + if (!apiStoreInfo.pasturestackCompatibility || apiStoreInfo.pasturestackCompatibility.revision !== 6) { fail("ember-api-store compatibility revision is missing"); } if (!emberFetchInfo.pasturestackCompatibility || emberFetchInfo.pasturestackCompatibility.revision !== 6) { @@ -725,6 +725,7 @@ function expectEmberApiStoreFetchUpgrade() { "addon/utils/fetch.js", "addon/utils/denormalize.js", "addon/utils/normalize.js", + "addon/utils/create-only-delivery.js", ]) { if (!fs.existsSync(path.join(apiStoreDir, filePath))) { fail(`ember-api-store required API file missing: ${filePath}`); @@ -738,15 +739,15 @@ 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"); + expectVendoredFileSha256("vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz", + "58079a9d1cc9539d89999f9fb3ac9f89464a1d79f45bb1c8ada18b8eda545bb6"); 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')"]) { + for (const marker of ["delete opt.createIdentity;", "if ( opt.method === 'POST' )", "Object.defineProperty(opt, 'createIdentity'", + "generation: get(store, 'generation')", "baseUrl: get(store, 'baseUrl')", "fields[key].readOnCreateOnly === true"]) { if (!typeMixin.includes(marker)) { fail(`ember-api-store create-only save identity marker missing: ${marker}`); } @@ -831,7 +832,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=5 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=6 ember-fetch=5.1.3 fetch_compat_revision=6 native_fetch=ok legacy_build_graph=absent"); } function expectBrowserGlobalBundle(file, globalName, expectedVersion) { diff --git a/tests/unit/mixins/new-or-edit-test.js b/tests/unit/mixins/new-or-edit-test.js index ed8b875c70..c37bd4936b 100644 --- a/tests/unit/mixins/new-or-edit-test.js +++ b/tests/unit/mixins/new-or-edit-test.js @@ -4,6 +4,7 @@ import { run } from '@ember/runloop'; import { defer, reject, resolve } from 'rsvp'; import { module, test } from 'qunit'; import NewOrEdit from 'ui/mixins/new-or-edit'; +import { takeCreateOnlyDelivery } from 'ember-api-store/utils/create-only-delivery'; module('Unit | Mixin | new or edit'); @@ -82,6 +83,79 @@ function save(subject, callback) { return subject.get('actions').save.call(subject, callback); } +test('opt-in delivery belongs to one save owner and is cleared on success, rejection and synchronous callback failures', async function(assert) { + for ( const mode of ['success', 'request-reject', 'doneSaving-throw', 'completion-throw'] ) { + const pending = defer(); + const failure = new Error(mode); + let options, callbackCalls = 0; + const subject = subjectWith({ + createOnlyDelivery: true, + doneSaving(value) { + assert.strictEqual(value, 'saved', 'existing hook argument is unchanged'); + if ( mode === 'doneSaving-throw' ) { throw failure; } + return value; + }, + }); + subject.get('model').save = value => { options = value; return pending.promise; }; + const operation = save(subject, () => { + callbackCalls++; + if ( mode === 'completion-throw' ) { throw failure; } + }); + for ( let turn = 0; !options && turn < 20; turn++ ) { await resolve(); } + assert.ok(options, 'base doSave binds private delivery options'); + const data = {fields: {secretValue: 'SECRET-TEST'}}; + takeCreateOnlyDelivery(options)(data); + assert.notOk(Object.keys(subject).includes('_createOnlyDelivery'), 'delivery metadata is nonenumerable'); + assert.notOk(Object.keys(subject).includes('_createOnlyRequest'), 'request metadata is nonenumerable'); + const owner = subject._saveOwner; + assert.deepEqual(await save(subject), {saved: false, reason: 'busy'}); + assert.strictEqual(subject._saveOwner, owner, 'duplicate cannot clear the owner'); + assert.strictEqual(subject._createOnlyDelivery.data, data, 'duplicate cannot consume the owner delivery'); + if ( mode === 'request-reject' ) { pending.reject(failure); } else { pending.resolve('saved'); } + try { + const result = await operation; + if ( mode === 'completion-throw' ) { assert.ok(false, 'callback failure must reject'); } + else if ( mode === 'success' ) { assert.strictEqual(result, 'saved', 'existing result is unchanged'); } + else { assert.strictEqual(result.error, failure); } + } catch (error) { + assert.strictEqual(error, failure); + assert.strictEqual(mode, 'completion-throw'); + } + assert.strictEqual(callbackCalls, 1); + assert.strictEqual(data.fields, null, `${mode} clears unconsumed one-time values`); + assert.strictEqual(takeCreateOnlyDelivery(options), null); + assert.strictEqual(subject._createOnlyRequest, null); + assert.strictEqual(subject._createOnlyDelivery, null); + assert.strictEqual(subject._saveOwner, null); + assert.strictEqual(subject.get('saving'), false); + run(() => subject.destroy()); + } +}); + +test('synchronous persistence failure clears private callback, while ordinary consumers keep their options and result', async function(assert) { + let options; + const subject = subjectWith({createOnlyDelivery: true}); + const failure = new Error('synchronous save failed'); + subject.get('model').save = value => { options = value; throw failure; }; + const outcome = await save(subject); + assert.strictEqual(outcome.error, failure); + assert.strictEqual(takeCreateOnlyDelivery(options), null); + assert.strictEqual(subject._createOnlyRequest, null); + assert.strictEqual(subject._saveOwner, null); + assert.strictEqual(subject.get('saving'), false); + run(() => subject.destroy()); + + const ordinary = subjectWith(); + ordinary.get('model').save = value => { + assert.strictEqual(value, undefined, 'non-opted-in consumers keep their previous arguments'); + return resolve('ordinary-saved'); + }; + assert.strictEqual(await save(ordinary), 'ordinary-saved'); + assert.strictEqual(ordinary._createOnlyRequest, undefined); + assert.strictEqual(ordinary._createOnlyDelivery, undefined); + run(() => ordinary.destroy()); +}); + test('validation cancellation returns an awaitable outcome and completes once', function(assert) { let callbacks = []; let saves = 0; diff --git a/tests/unit/vendor/api-store-create-order-test.js b/tests/unit/vendor/api-store-create-order-test.js index ec45c59669..2caf2749a7 100644 --- a/tests/unit/vendor/api-store-create-order-test.js +++ b/tests/unit/vendor/api-store-create-order-test.js @@ -6,13 +6,18 @@ 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'; +import { bindCreateOnlyDelivery, cloneCreateOnlyDelivery, takeCreateOnlyDelivery } from 'ember-api-store/utils/create-only-delivery'; +import EditApiKey from 'ui/components/edit-apikey/component'; +import EmberObject from '@ember/object'; +import inertRenderer from '../../helpers/inert-renderer'; +import { createOwned, destroyOwned } from '../../helpers/owned-subject'; // 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') { +function fixture(project = '1a-test', baseUrl = `/v2-beta/projects/${project}`) { const objects = new Set(); const requests = []; - const store = Store.create({ baseUrl: `/v2-beta/projects/${project}` }); + const store = Store.create({ baseUrl }); setOwner(store, { lookup(name) { if ( name === 'service:fastboot' ) { return { isFastBoot: false }; } const Factory = name === 'model:schema' ? Schema : @@ -33,6 +38,13 @@ function fixture(project = '1a-test') { type: 'schema', id, resourceFields: {}, collectionMethods: ['GET', 'POST'], links: { collection: `${store.baseUrl}/${id}s` }, }))); + store._bulkAdd('schema', [{type: 'schema', id: 'apiKey', + collectionMethods: ['GET', 'POST'], resourceFields: { + name: {type: 'string', create: true}, + publicValue: {type: 'string', create: false}, + secretValue: {type: 'password', create: false, update: false, readOnCreateOnly: true}, + nested: {type: 'service'}, + }, links: {collection: `${store.baseUrl}/apikeys`}}]); return { store, requests, response, destroy() { store.all('schema').forEach(object => objects.add(object)); @@ -52,6 +64,138 @@ const current = (type = 'volume', id = 'Opaque-ID') => ({ }); module('Unit | Vendor | API store create response order', function() { + test('apiKey first delivery survives redacted subscribe before 201 and later redaction in personal and project stores, 100 deterministic barriers each', async function(assert) { + for ( const [accountId, baseUrl] of [['1a-owner', '/v2-beta'], ['1a-project', '/v2-beta/projects/1a-project']] ) { + for ( let index = 0; index < 100; index++ ) { + const f = fixture(accountId, baseUrl); + let subject; + try { + const original = f.store.createRecord({type: 'apiKey', name: 'created', accountId}); + const draft = original.clone(); + subject = createOwned(EditApiKey, { + renderer: inertRenderer(), + intl: EmberObject.create({t(key) { return key; }}), + modalService: EmberObject.create({modalOpts: original}), + model: draft, + clone: original.clone(), + didSave(resource) { + assert.strictEqual(imports, 0, '201 adoption performs no stale mangleIn or nested import'); + return resource; + }, + }, 'component'); + const completion = []; + const saving = run(() => subject.get('actions').save.call(subject, success => completion.push(success))); + // Await the actual willSave/doSave RSVP turns, not a clock delay. + for ( let turn = 0; !f.requests.length && turn < 20; turn++ ) { await resolve(); } + assert.strictEqual(f.requests.length, 1, 'the actual save reaches its deferred HTTP boundary'); + const nested = run(() => f.store._typeify({...current('service', 'Nested-ID'), state: 'active'})); + run(() => f.store._typeify({type: 'apiKey', id: 'Key-ID', accountId, + name: 'created', state: 'active', publicValue: 'PUBLIC-TEST', secretValue: null, nested})); + let imports = 0; + const createRecord = f.store.createRecord; + f.store.createRecord = (...args) => { imports++; return createRecord(...args); }; + const body = {type: 'apiKey', id: 'Key-ID', accountId, name: 'created', + state: 'registering', publicValue: 'PUBLIC-TEST', secretValue: 'SECRET-TEST', + nested: {...initial('service', 'Nested-ID'), state: 'creating'}}; + const xhr = {status: 201, body}; + run(() => f.response.resolve(xhr)); + await saving; + const canonical = f.store.getById('apiKey', 'Key-ID'); + const clone = subject.get('clone'); + assert.strictEqual(canonical, draft, 'canonical save identity is retained'); + assert.strictEqual(canonical.get('state'), 'active', 'newer cached state wins'); + assert.strictEqual(nested.get('state'), 'active', 'stale nested 201 is not imported'); + assert.strictEqual(clone.get('secretValue'), 'SECRET-TEST', 'actual API-key doneSaving receives one-time secret'); + assert.strictEqual(clone.get('publicValue'), 'PUBLIC-TEST'); + assert.strictEqual(canonical.get('secretValue'), null, 'canonical store never needs to retain secret'); + assert.notOk(JSON.stringify(canonical.serialize()).includes('SECRET-TEST')); + assert.notOk(Object.hasOwn(f.requests[0].data, 'createIdentity')); + assert.notOk(Object.keys(f.requests[0]).includes('createIdentity'), 'create marker is nonenumerable'); + assert.notOk(JSON.stringify(f.requests[0]).includes('SECRET-TEST'), 'request metadata contains no secret'); + assert.deepEqual(completion, [true]); + assert.strictEqual(subject._createOnlyDelivery, null); + assert.strictEqual(subject._createOnlyRequest, null); + assert.strictEqual(subject._saveOwner, null); + assert.strictEqual(subject.get('saving'), false); + assert.notOk(xhr.body, 'raw 201 body is not retained'); + run(() => f.store._typeify({type: 'apiKey', id: 'Key-ID', accountId, + name: 'created', state: 'active', publicValue: 'PUBLIC-TEST', secretValue: null})); + assert.strictEqual(canonical.get('secretValue'), null); + assert.strictEqual(clone.get('secretValue'), 'SECRET-TEST', 'later WS cannot erase detached visible delivery'); + assert.strictEqual(f.requests.length, 1, 'no replay or extra request'); + } finally { + if ( subject ) { destroyOwned(subject); } + f.destroy(); + } + } + } + }); + + test('uncached API-key create-only delivery is one-shot and schema-bound, with private request metadata', async function(assert) { + const f = fixture(); + try { + let data, calls = 0; + const options = {}; + bindCreateOnlyDelivery(options, value => { data = value; calls++; }); + const draft = f.store.createRecord({type: 'apiKey', name: 'created'}); + const saving = run(() => draft.save(options)); + run(() => f.response.resolve({status: 201, body: {type: 'apiKey', id: 'Key-ID', + state: 'requested', name: 'created', secretValue: 'SECRET-TEST', publicValue: 'PUBLIC-TEST'}})); + assert.strictEqual(await saving, draft); + assert.strictEqual(calls, 1); + assert.deepEqual(data.fields, {secretValue: 'SECRET-TEST'}, 'only exact readOnCreateOnly=true fields are delivered'); + assert.strictEqual(draft.get('secretValue'), null); + const clone = cloneCreateOnlyDelivery(draft, data); + assert.strictEqual(clone.get('secretValue'), 'SECRET-TEST'); + assert.strictEqual(data.fields, null, 'delivery is consumed'); + assert.throws(() => cloneCreateOnlyDelivery(draft, data), /no longer belongs/); + assert.strictEqual(takeCreateOnlyDelivery(options), null); + } finally { f.destroy(); } + }); + + test('create-only delivery rejects changed store, generation, base, concrete type and owner without importing its secret', async function(assert) { + for ( const change of ['generation', 'base', 'type', 'owner'] ) { + const f = fixture(); + try { + let calls = 0; + const options = {}; + bindCreateOnlyDelivery(options, () => calls++); + const draft = f.store.createRecord({type: 'apiKey', accountId: '1a-test'}); + const saving = run(() => draft.save(options)); + if ( change === 'generation' ) { run(() => f.store.reset()); } + if ( change === 'base' ) { f.store.set('baseUrl', '/v2-beta/projects/other'); } + if ( change === 'owner' ) { run(() => f.store._typeify({type: 'apiKey', id: 'Key-ID', accountId: '1a-other', state: 'active', secretValue: null})); } + run(() => f.response.resolve({status: 201, body: {type: change === 'type' ? 'volume' : 'apiKey', id: 'Key-ID', accountId: '1a-test', secretValue: 'SECRET-TEST'}})); + await saving; + assert.strictEqual(calls, 0, `${change} cannot receive first delivery`); + assert.notStrictEqual(draft.get('secretValue'), 'SECRET-TEST'); + } finally { f.destroy(); } + } + const f = fixture(), other = fixture(); + try { + const resource = other.store._typeify({type: 'apiKey', id: 'Key-ID'}); + assert.throws(() => cloneCreateOnlyDelivery(resource, {id: 'Key-ID', type: 'apikey', + store: f.store, generation: f.store.generation, baseUrl: f.store.baseUrl, fields: {secretValue: 'SECRET-TEST'}}), /no longer belongs/); + } finally { f.destroy(); other.destroy(); } + }); + + test('a synchronous first-delivery callback exception is consumed once and cannot replay create', async function(assert) { + const f = fixture(); + try { + let calls = 0, delivered; + const options = {}; + bindCreateOnlyDelivery(options, value => { delivered = value; calls++; throw new Error('delivery callback failed'); }); + const draft = f.store.createRecord({type: 'apiKey'}); + const saving = run(() => draft.save(options)); + run(() => f.response.resolve({status: 201, body: {type: 'apiKey', id: 'Key-ID', secretValue: 'SECRET-TEST'}})); + try { await saving; assert.ok(false); } catch (error) { assert.ok(error); } + assert.strictEqual(calls, 1); + assert.strictEqual(delivered.fields, null, 'synchronous callback failure clears one-time values'); + assert.strictEqual(takeCreateOnlyDelivery(options), null); + assert.strictEqual(f.requests.length, 1); + assert.strictEqual(f.store.getById('apiKey', 'Key-ID').get('secretValue'), null); + } finally { f.destroy(); } + }); 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(); diff --git a/vendor/ember-api-store-compat/UPSTREAM.md b/vendor/ember-api-store-compat/UPSTREAM.md index 35113d99fb..d13e346145 100644 --- a/vendor/ember-api-store-compat/UPSTREAM.md +++ b/vendor/ember-api-store-compat/UPSTREAM.md @@ -38,3 +38,13 @@ 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. + +Compatibility revision 6 adds opt-in first delivery of Schema fields explicitly +marked `readOnCreateOnly`, exported by the Engine auth overlay's `o` permission. +The create request captures only their field names. A request-private, +nonenumerable callback transports the successful 201 values once to a detached +consumer clone, outside the canonical store and serialized request. This keeps +revision 5's newer subscribe state and nested-resource adoption intact, validates +the same store, generation, API base, generated ID, concrete type and owner, and +does not require a canonical resource to retain secrets after create. Existing +save hooks, non-opted-in consumers, errors, and request counts are unchanged. diff --git a/vendor/ember-api-store-compat/addon/mixins/type.js b/vendor/ember-api-store-compat/addon/mixins/type.js index e240df0752..c0ff4aa15a 100644 --- a/vendor/ember-api-store-compat/addon/mixins/type.js +++ b/vendor/ember-api-store-compat/addon/mixins/type.js @@ -164,11 +164,13 @@ var Type = Mixin.create(Serializable,{ // 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'), - }; + const schema = store.getById('schema', type); + const fields = schema && get(schema, 'store') === store ? get(schema, 'resourceFields') || {} : {}; + Object.defineProperty(opt, 'createIdentity', { + configurable: true, + value: { type, generation: get(store, 'generation'), baseUrl: get(store, 'baseUrl'), + readOnCreateFields: Object.keys(fields).filter(key => fields[key].readOnCreateOnly === true) }, + }); } } diff --git a/vendor/ember-api-store-compat/addon/services/store.js b/vendor/ember-api-store-compat/addon/services/store.js index 8f3700eecd..024524ad65 100644 --- a/vendor/ember-api-store-compat/addon/services/store.js +++ b/vendor/ember-api-store-compat/addon/services/store.js @@ -11,6 +11,7 @@ import { reject, resolve, defer } from 'rsvp'; import Service, { service } from '@ember/service'; import { isArray } from '@ember/array'; import { parse as setCookieParser } from 'set-cookie-parser'; +import { takeCreateOnlyDelivery } from '../utils/create-only-delivery'; function getOwnerKey() { const x = {}; @@ -510,6 +511,7 @@ var Store = Service.extend({ _requestSuccess(xhr,opt) { opt.responseStatus = xhr.status; + const firstDelivery = takeCreateOnlyDelivery(opt); if ( xhr.status === 204 ) { return; @@ -517,7 +519,24 @@ var Store = Service.extend({ if ( xhr.body && typeof xhr.body === 'object' ) { let response; + let delivery; const creation = opt.createIdentity; + const createOnlyFields = {}; + // Keep one-time fields out of the canonical import even if the original + // store generation changed before the response arrived. A stale marker + // must neither deliver its values nor retain them in a different cache. + if ( firstDelivery && creation && xhr.status === 201 && opt.method === 'POST' ) { + // Field names were captured from this request's actual Schema, so a + // later reset cannot turn a one-time value into a canonical field. + (creation.readOnCreateFields || []).forEach((key) => { + if ( Object.hasOwn(xhr.body, key) ) { + if ( xhr.body[key] !== null && xhr.body[key] !== undefined ) { + createOnlyFields[key] = JSON.parse(JSON.stringify(xhr.body[key])); + } + xhr.body[key] = null; + } + }); + } // 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 @@ -533,10 +552,26 @@ var Store = Service.extend({ get(cached, 'store') === this && this.hasRecord(cached) ) { response = cached; } + if ( Object.keys(createOnlyFields).length && + (!response || get(response, 'accountId') === xhr.body.accountId) ) { + // Engine's auth overlay "o" exports readOnCreateOnly. Transport + // only those schema-bound values; no stale state or nested imports. + delivery = { id: xhr.body.id, type: creation.type, store: this, + accountId: xhr.body.accountId, generation: creation.generation, + baseUrl: creation.baseUrl, fields: createOnlyFields }; + } } response = response || this._typeify(xhr.body); delete xhr.body; Object.defineProperty(response, 'xhr', {value: xhr, configurable: true}); + if ( delivery ) { + try { + firstDelivery(delivery); + } catch (error) { + delivery.fields = null; + throw error; + } + } // Depaginate if ( opt.depaginate && typeof response.depaginate === 'function' ) { @@ -554,6 +589,7 @@ var Store = Service.extend({ }, _requestFailed(xhr,opt) { + takeCreateOnlyDelivery(opt); var body; if ( xhr.err ) { diff --git a/vendor/ember-api-store-compat/addon/utils/create-only-delivery.js b/vendor/ember-api-store-compat/addon/utils/create-only-delivery.js new file mode 100644 index 0000000000..c13aa7a397 --- /dev/null +++ b/vendor/ember-api-store-compat/addon/utils/create-only-delivery.js @@ -0,0 +1,36 @@ +import { get } from '@ember/object'; +import { normalizeType } from './normalize'; + +// Request-local metadata: not a resource field, payload key, or store cache. +const callbackKey = Symbol('create-only first delivery'); + +export function bindCreateOnlyDelivery(options, callback) { + Object.defineProperty(options, callbackKey, { value: callback, configurable: true }); +} + +export function takeCreateOnlyDelivery(options) { + const callback = options[callbackKey]; + delete options[callbackKey]; + return typeof callback === 'function' ? callback : null; +} + +export function cloneCreateOnlyDelivery(resource, delivery) { + if ( !delivery ) { + return resource.clone(); + } + + const fields = delivery.fields; + delivery.fields = null; // Consume even when identity validation or cloning fails. + const store = get(resource, 'store'); + if ( !fields || store !== delivery.store || get(resource, 'id') !== delivery.id || + normalizeType(get(resource, 'type')) !== delivery.type || + get(resource, 'accountId') !== delivery.accountId || + get(store, 'generation') !== delivery.generation || + get(store, 'baseUrl') !== delivery.baseUrl ) { + throw new Error('Create-only delivery no longer belongs to this save'); + } + + const clone = resource.clone(); + clone.setProperties(fields); + return clone; +} diff --git a/vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz b/vendor/ember-api-store-compat/ember-api-store-2.8.5-pasturestack.6.tgz new file mode 100644 index 0000000000000000000000000000000000000000..b4c9c262ee13cbdef5d5c2018ab821a7adeae65f GIT binary patch literal 24320 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" }, "pasturestackCompatibility": { - "revision": 5, + "revision": 6, "upstreamPackage": "ember-api-store", "upstreamVersion": "2.8.5", "upstreamIntegrity": "sha512-YvnBZfdNGG7hB25hecEENHObXNN+186Bbkd1wAIL7qtRXqboQsQxTU05xxP7axgW6TPYfUYMxZ+GgbHgD14g2A==" From f3ae04c0ae648424432e453634189892deef4f02 Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:29:15 +0800 Subject: [PATCH 8/9] test: bind real model validation and deferred create delivery --- .../vendor/api-store-create-order-test.js | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/tests/unit/vendor/api-store-create-order-test.js b/tests/unit/vendor/api-store-create-order-test.js index 2caf2749a7..c66b5e6a7b 100644 --- a/tests/unit/vendor/api-store-create-order-test.js +++ b/tests/unit/vendor/api-store-create-order-test.js @@ -9,6 +9,7 @@ import Collection from 'ember-api-store/models/collection'; import { bindCreateOnlyDelivery, cloneCreateOnlyDelivery, takeCreateOnlyDelivery } from 'ember-api-store/utils/create-only-delivery'; import EditApiKey from 'ui/components/edit-apikey/component'; import EmberObject from '@ember/object'; +import Service from '@ember/service'; import inertRenderer from '../../helpers/inert-renderer'; import { createOwned, destroyOwned } from '../../helpers/owned-subject'; @@ -17,8 +18,11 @@ import { createOwned, destroyOwned } from '../../helpers/owned-subject'; function fixture(project = '1a-test', baseUrl = `/v2-beta/projects/${project}`) { const objects = new Set(); const requests = []; + const intl = Service.create({exists() { return false; }, t(key) { return key; }}); + objects.add(intl); const store = Store.create({ baseUrl }); setOwner(store, { lookup(name) { + if ( name === 'service:intl' ) { return intl; } if ( name === 'service:fastboot' ) { return { isFastBoot: false }; } const Factory = name === 'model:schema' ? Schema : name === 'model:collection' ? Collection : Resource; @@ -29,11 +33,19 @@ function fixture(project = '1a-test', baseUrl = `/v2-beta/projects/${project}`) const createRecord = store.createRecord.bind(store); store.createRecord = (...args) => { const object = createRecord(...args); + // The real Resource validator reads model.intl, not component.intl. + // Service injection is not an enumerable resource/API payload field. + Object.defineProperty(object, 'intl', {value: intl, configurable: true}); objects.add(object); return object; }; const response = defer(); - store.rawRequest = (options) => { requests.push(options); return response.promise; }; + const requestEntered = defer(); + store.rawRequest = (options) => { + requests.push(options); + requestEntered.resolve(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` }, @@ -45,7 +57,7 @@ function fixture(project = '1a-test', baseUrl = `/v2-beta/projects/${project}`) secretValue: {type: 'password', create: false, update: false, readOnCreateOnly: true}, nested: {type: 'service'}, }, links: {collection: `${store.baseUrl}/apikeys`}}]); - return { store, requests, response, + return { store, requests, response, requestEntered, intl, destroy() { store.all('schema').forEach(object => objects.add(object)); run(() => { @@ -74,7 +86,7 @@ module('Unit | Vendor | API store create response order', function() { const draft = original.clone(); subject = createOwned(EditApiKey, { renderer: inertRenderer(), - intl: EmberObject.create({t(key) { return key; }}), + intl: f.intl, modalService: EmberObject.create({modalOpts: original}), model: draft, clone: original.clone(), @@ -83,11 +95,28 @@ module('Unit | Vendor | API store create response order', function() { return resource; }, }, 'component'); + const savingModel = subject.get('primaryResource'); + assert.strictEqual(savingModel, subject.get('model'), 'actual editor saves its model'); + assert.strictEqual(savingModel.get('intl'), f.intl, 'actual Resource receives the shared intl service'); + assert.deepEqual(savingModel.validationErrors(), [], 'real model validation accepts the fixture without bypassing willSave'); + assert.notOk(Object.hasOwn(savingModel.serialize(), 'intl'), 'service is not serialized into create payload'); + assert.strictEqual(savingModel.get('accountId'), accountId); + assert.notOk(savingModel.get('id'), 'actual editor starts with an ID-less draft'); const completion = []; const saving = run(() => subject.get('actions').save.call(subject, success => completion.push(success))); - // Await the actual willSave/doSave RSVP turns, not a clock delay. - for ( let turn = 0; !f.requests.length && turn < 20; turn++ ) { await resolve(); } + // Wait for real transport entry. A cancelled/failed lifecycle wins + // the race instead of hanging or assuming N microtask turns suffice. + const boundary = await Promise.race([ + f.requestEntered.promise.then(options => ({options})), + saving.then(outcome => ({earlyOutcome: outcome}), error => ({earlyError: error})), + ]); + const earlyError = boundary.earlyError || (boundary.earlyOutcome && boundary.earlyOutcome.error); + const diagnosis = {reason: boundary.earlyOutcome && boundary.earlyOutcome.reason, + saved: boundary.earlyOutcome && boundary.earlyOutcome.saved, + error: earlyError && earlyError.message, errors: subject.get('errors')}; + assert.ok(boundary.options, `actual request entered, otherwise early lifecycle: ${JSON.stringify(diagnosis)}`); assert.strictEqual(f.requests.length, 1, 'the actual save reaches its deferred HTTP boundary'); + if ( !boundary.options ) { return; } const nested = run(() => f.store._typeify({...current('service', 'Nested-ID'), state: 'active'})); run(() => f.store._typeify({type: 'apiKey', id: 'Key-ID', accountId, name: 'created', state: 'active', publicValue: 'PUBLIC-TEST', secretValue: null, nested})); @@ -102,7 +131,7 @@ module('Unit | Vendor | API store create response order', function() { await saving; const canonical = f.store.getById('apiKey', 'Key-ID'); const clone = subject.get('clone'); - assert.strictEqual(canonical, draft, 'canonical save identity is retained'); + assert.strictEqual(canonical, savingModel, 'actual editor draft canonical save identity is retained'); assert.strictEqual(canonical.get('state'), 'active', 'newer cached state wins'); assert.strictEqual(nested.get('state'), 'active', 'stale nested 201 is not imported'); assert.strictEqual(clone.get('secretValue'), 'SECRET-TEST', 'actual API-key doneSaving receives one-time secret'); @@ -184,11 +213,18 @@ module('Unit | Vendor | API store create response order', function() { try { let calls = 0, delivered; const options = {}; - bindCreateOnlyDelivery(options, value => { delivered = value; calls++; throw new Error('delivery callback failed'); }); + const failure = new Error('delivery callback failed'); + bindCreateOnlyDelivery(options, value => { delivered = value; calls++; throw failure; }); const draft = f.store.createRecord({type: 'apiKey'}); const saving = run(() => draft.save(options)); + // Attach before fulfilling HTTP: RSVP must not report an unhandled + // rejection while the native async test has not resumed yet. + const handled = saving.then(value => ({value}), error => ({error})); run(() => f.response.resolve({status: 201, body: {type: 'apiKey', id: 'Key-ID', secretValue: 'SECRET-TEST'}})); - try { await saving; assert.ok(false); } catch (error) { assert.ok(error); } + const outcome = await handled; + assert.ok(outcome.error, 'callback failure rejects create completion'); + assert.strictEqual(outcome.error.get('message'), failure.message, 'existing API error message is retained'); + assert.strictEqual(outcome.error.xhr, failure, 'existing API error wrapper retains the exact original exception'); assert.strictEqual(calls, 1); assert.strictEqual(delivered.fields, null, 'synchronous callback failure clears one-time values'); assert.strictEqual(takeCreateOnlyDelivery(options), null); From daab6e8ed5206562feb60e6549a3b9e72b4c8381 Mon Sep 17 00:00:00 2001 From: chen21019 <19357113+chen21019@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:29:27 +0800 Subject: [PATCH 9/9] release: sign exact Web172 regression candidate