From 4c88201b68135bd0a745224060d24a65de18110b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 15:35:41 +0000 Subject: [PATCH 1/2] fix(sharing): key the territory rules off a flat billing_country column (#621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both territory sharing rules were declared against `record.billing_address.country`, a path that reaches inside the composite `address` value. A sharing rule's condition is compiled to a pushdown-able query filter by `compileCelToFilter`, which rejects every such path, and `plugin-sharing` then refuses to seed the rule rather than degrade it to match-all. The result was silent: `seeded: 7, skipped: 2, total: 9` on every boot, with `na_sales_team` / `eu_sales_team` receiving no criteria-based account access at all while the metadata and the admin docs said they did. Measured, the blocker is the NESTED PATH and not the `in [...]` operator: `in`, `==`, `!=`, ordering, `&&`, `||`, `!`, `startsWith()` and `== null` all compile against a flat field, while `==` on the nested path fails exactly like `in` does. Issue #621's option A (rewriting `in [...]` as a disjunction of `==`) could therefore never have worked; only a flat column can. `crm_account` now carries `billing_country`, a readonly projection of `billing_address.country` (trimmed, upper-cased) maintained by `account_protection` on every write that carries the address and left untouched by writes that do not. Only the `country` slot is read — `countryCode` holds ISO 3166-1 alpha-2, where the UK is `GB`, and the Europe rule is authored against `UK`, so preferring the ISO slot would silently evict UK accounts from their own territory. Territory membership is unchanged; only the column the rules read from is. `test/sharing-seeding.test.ts` asserts the seeded OUTCOME rather than the declared shape — it compiles every declared rule with the platform's own compiler, requires `seeded + 0 skipped`, and requires every field a rule filters on to be a real flat column of its object. It also pins the measured operator matrix, including that `has()` does not compile in a sharing condition (load-bearing for #633). Verified on a fresh boot: `{"seeded":9,"skipped":0,"total":9}`, and the seeded `europe_territory` row carries `criteria_json: {"billing_country":{"$in":["UK","DE","FR","IT","ES"]}}`. An account POSTed with `billing_address.country = " de "` reads back `billing_country: "DE"`, and an unrelated PATCH leaves it intact. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf --- .../territory-sharing-billing-country.md | 28 ++ .../administration/sharing-and-security.mdx | 11 + .../sharing-and-security.zh-Hans.mdx | 2 + .../sharing-and-security.zh-Hant.mdx | 2 + content/docs/sales/accounts.mdx | 8 + package.json | 1 + pnpm-lock.yaml | 3 + src/objects/account.hook.ts | 39 +++ src/objects/account.object.ts | 51 +++ src/sharing/account.sharing.ts | 24 +- src/translations/en.ts | 4 + src/translations/es-ES.ts | 4 + src/translations/ja-JP.ts | 4 + src/translations/zh-CN.ts | 4 + src/views/account.view.ts | 7 +- test/action-sandbox.test.ts | 41 +++ test/hooks-runtime-sales.test.ts | 71 +++++ test/sharing-seeding.test.ts | 296 ++++++++++++++++++ 18 files changed, 596 insertions(+), 4 deletions(-) create mode 100644 .changeset/territory-sharing-billing-country.md create mode 100644 test/sharing-seeding.test.ts diff --git a/.changeset/territory-sharing-billing-country.md b/.changeset/territory-sharing-billing-country.md new file mode 100644 index 00000000..7136965a --- /dev/null +++ b/.changeset/territory-sharing-billing-country.md @@ -0,0 +1,28 @@ +--- +'hotcrm': patch +--- + +Fix territory sharing: the North America and Europe rules now actually grant +access. Both were declared against `record.billing_address.country`, a path +that reaches inside the structured Billing Address value. A sharing rule's +criteria have to compile into a database query, and a query cannot reach inside +a composite address — so the platform refused to install either rule (correctly +preferring that to widening them to "every account"), and `na_sales_team` / +`eu_sales_team` received no criteria-based account access at all while the +metadata and the admin docs said they did. The only sign was a WARN in the boot +log: `seeded: 7, skipped: 2, total: 9`. + +Accounts now carry **Billing Country**, a read-only two-letter code projected +from the country you enter in Billing Address and maintained on every write, and +the two territory rules match on it. Territory membership is unchanged — the +same countries, read from a queryable column instead of from inside the address +— and the field is shown on the account's *Locations* section so an admin can +see at a glance why a territory team does or does not have an account. Enter the +billing country as its two-letter code (`US`, `DE`, …); a country spelled out in +full puts the account in no territory. + +For anyone writing their own rules: **criteria may only filter on plain fields**, +never on part of an Address or Location value. `test/sharing-seeding.test.ts` +now compiles every declared rule with the platform's own compiler and fails the +build if any of them would be dropped at boot, so a rule can no longer ship +inert. Fixes #621. diff --git a/content/docs/administration/sharing-and-security.mdx b/content/docs/administration/sharing-and-security.mdx index 785570de..3b06de58 100644 --- a/content/docs/administration/sharing-and-security.mdx +++ b/content/docs/administration/sharing-and-security.mdx @@ -95,6 +95,17 @@ Criteria-based rules are the enforced flavour: matching records materialise real Create your own in **Setup → Sharing Settings**. +> **Criteria filter on plain fields, never on part of an Address.** The two +> territory rules match on the account's **Billing Country** — a read-only +> two-letter code derived from the country you type into **Billing Address** — +> and not on the address itself. A criteria rule has to run as a database +> query, and a query cannot reach inside a structured Address or Location +> value. A rule written against `Billing Address → Country` is therefore +> **rejected outright** rather than quietly widened to "every account": it is +> never installed, and the position it names receives nothing. Nothing in the +> UI marks such a rule as broken, so when a team reports missing records, check +> that every field the rule names is a plain field on the object. + ### A rule widens one object, not the records underneath it Sharing rules are authored **per object**. Widening `Account` widens accounts — the records hanging off a shared account keep their own baseline, and Contact is the only one derived from it: diff --git a/content/docs/administration/sharing-and-security.zh-Hans.mdx b/content/docs/administration/sharing-and-security.zh-Hans.mdx index 050de5f3..e503b92e 100644 --- a/content/docs/administration/sharing-and-security.zh-Hans.mdx +++ b/content/docs/administration/sharing-and-security.zh-Hans.mdx @@ -95,6 +95,8 @@ na_sales_team eu_sales_team (区域分组) 在 **设置 → 共享设置** 中创建你自己的规则。 +> **条件只能过滤普通字段,不能过滤地址的某一部分。** 两条区域规则匹配的是客户上的 **账单国家** —— 一个只读的两位国家代码,由你在 **账单地址** 中填写的国家推导而来 —— 而不是地址本身。条件规则必须以数据库查询的形式运行,而查询无法深入结构化的地址或位置值内部。因此,针对 `账单地址 → 国家` 编写的规则会被**直接拒绝**,而不是被悄悄放宽成"所有客户":它根本不会被安装,它指定的岗位也就什么都拿不到。界面上不会把这样的规则标记为失效,所以当某个团队反馈记录缺失时,请检查规则里引用的每个字段是否都是该对象上的普通字段。 + ### 一条规则放开的是一个对象,而不是它下面的记录 共享规则是 **按对象** 编写的。放开 `Account` 只放开客户本身 —— 挂在这个客户下的记录仍然各自守着自己的基线,其中只有联系人是从客户派生的: diff --git a/content/docs/administration/sharing-and-security.zh-Hant.mdx b/content/docs/administration/sharing-and-security.zh-Hant.mdx index eb0b426a..c33b155f 100644 --- a/content/docs/administration/sharing-and-security.zh-Hant.mdx +++ b/content/docs/administration/sharing-and-security.zh-Hant.mdx @@ -95,6 +95,8 @@ na_sales_team eu_sales_team (區域分組) 在 **設定 → 共用設定** 中建立你自己的規則。 +> **條件只能過濾普通欄位,不能過濾地址的某一部分。** 兩條區域規則比對的是客戶上的 **帳單國家** —— 一個唯讀的兩位國家代碼,由你在 **帳單地址** 中填寫的國家推導而來 —— 而不是地址本身。條件規則必須以資料庫查詢的形式執行,而查詢無法深入結構化的地址或位置值內部。因此,針對 `帳單地址 → 國家` 撰寫的規則會被**直接拒絕**,而不是被悄悄放寬成「所有客戶」:它根本不會被安裝,它指定的職位也就什麼都拿不到。介面上不會把這樣的規則標記為失效,所以當某個團隊回報記錄缺失時,請檢查規則裡引用的每個欄位是否都是該物件上的普通欄位。 + ### 一條規則放開的是一個物件,而不是它底下的記錄 共用規則是 **按物件** 撰寫的。放開 `Account` 只放開客戶本身 —— 掛在這個客戶底下的記錄仍各自守著自己的基線,其中只有聯絡人是從客戶衍生的: diff --git a/content/docs/sales/accounts.mdx b/content/docs/sales/accounts.mdx index e05f0bdf..574dfbba 100644 --- a/content/docs/sales/accounts.mdx +++ b/content/docs/sales/accounts.mdx @@ -93,6 +93,14 @@ On top of that: - North America (US, CA, MX) → North America sales team - Europe (UK, DE, FR, IT, ES) → Europe sales team +The territory rules match on **Billing Country**, a read-only field on the +account that is filled in from the country you enter in **Billing Address**. +Enter the country as its two-letter code (`US`, `DE`, …) — that is what the +rules compare against, so an account whose billing country reads +"United States" lands in no territory. The field is on the *Locations* section +of the account, which makes it the first thing to check when a territory team +says an account is missing from their list. + Admins can change these rules — see [Administration › Sharing](/docs/administration/sharing-and-security). ## Who can edit what diff --git a/package.json b/package.json index 0c72e53f..cebf935b 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ }, "devDependencies": { "@changesets/cli": "^2.31.1", + "@objectstack/formula": "17.0.0-rc.1", "@playwright/test": "^1.61.1", "@vitest/coverage-v8": "^4.1.10", "tsx": "^4.23.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 311a29d8..596516e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@changesets/cli': specifier: ^2.31.1 version: 2.31.1 + '@objectstack/formula': + specifier: 17.0.0-rc.1 + version: 17.0.0-rc.1 '@playwright/test': specifier: ^1.61.1 version: 1.62.0 diff --git a/src/objects/account.hook.ts b/src/objects/account.hook.ts index bb7b8623..2a4f552b 100644 --- a/src/objects/account.hook.ts +++ b/src/objects/account.hook.ts @@ -7,6 +7,8 @@ import type { HookApi } from './_hook-api'; * Account protection hook. * * - Validates `website` format and `annual_revenue` non-negative. + * - Projects `billing_address.country` onto the flat `billing_country` column + * the territory sharing rules filter on (#621). * - Refuses to delete a `customer` account that still has open opportunities. */ const accountHook: Hook = { @@ -28,6 +30,43 @@ const accountHook: Hook = { if (typeof input.annual_revenue === 'number' && input.annual_revenue < 0) { throw new Error('Annual Revenue must be greater than or equal to 0'); } + + // ─── Territory projection (#621) ─────────────────────────────────── + // + // `billing_country` is the flat column the two territory sharing rules + // filter on, and this block is its only writer. It exists because a + // sharing rule's CEL condition is compiled into a pushdown-able query + // filter, and that compiler rejects any path reaching INSIDE a composite + // `address` value — `record.billing_address.country in [...]` is not + // translatable, so plugin-sharing dropped both rules on every boot and + // `na_sales_team` / `eu_sales_team` got nothing at all. + // + // Recompute ONLY when the write carries the address: a partial update + // that never mentions `billing_address` must leave `billing_country` + // alone, or every unrelated edit would blank the column and silently + // evict the account from its territory. A write that CLEARS the address + // (`billing_address: null`) does clear the projection — the key is + // present, the value is empty. + // + // Only `country` is read. `countryCode` is the ISO 3166-1 alpha-2 slot, + // where the United Kingdom is `GB`, while the Europe rule is authored + // against `UK`; preferring the ISO slot would silently drop UK accounts + // out of their own territory. Mirroring the one slot the rules have + // always named keeps this a change of STORAGE LOCATION, not of rule + // semantics. + // + // Written inline rather than as a module-scope helper on purpose: hook + // bodies must lower to metadata-only (no free identifiers), which + // `test/action-sandbox.test.ts` enforces for every registered hook. + if ('billing_address' in input) { + const address = input.billing_address; + const country = + address !== null && typeof address === 'object' && !Array.isArray(address) + ? (address as { country?: unknown }).country + : undefined; + const normalized = typeof country === 'string' ? country.trim().toUpperCase() : ''; + input.billing_country = normalized === '' ? null : normalized; + } } // Stamp last_activity_date when ownership or type changes (migrated from the diff --git a/src/objects/account.object.ts b/src/objects/account.object.ts index 2f3fafbb..6e1a7c7e 100644 --- a/src/objects/account.object.ts +++ b/src/objects/account.object.ts @@ -119,6 +119,54 @@ export const Account = ObjectSchema.create({ group: 'contact_info', }), + /** + * Flat projection of `billing_address.country` — the column the territory + * sharing rules filter on (#621). + * + * ### Why this field exists + * + * `billing_address` is an `address` field: the platform stores the whole + * {street, city, state, postalCode, country, countryCode, formatted} + * value in ONE column. A sharing rule's CEL condition is compiled to a + * pushdown-able `FilterCondition` by `compileCelToFilter`, and that + * compiler rejects every path that reaches INSIDE such a value: + * + * record.billing_address.country in ["US","CA","MX"] + * → unsupported: cross-object/nested field path + * "record.billing_address.country" is not pushdown-able + * + * `plugin-sharing` then refuses to seed the rule rather than degrade it to + * match-all, so both territory rules were dropped on every boot and + * `na_sales_team` / `eu_sales_team` received nothing at all. Measured: the + * blocker is the NESTED PATH, not the `in [...]` operator — `in [...]`, + * `==`, `!=`, `<`, `>`, `&&`, `||`, `!`, `startsWith()` and `== null` all + * compile fine against a FLAT field. Rewriting the condition as a + * disjunction of `==` (issue #621 option A) would therefore NOT have + * helped; only a flat column does. See `test/sharing-seeding.test.ts`, + * which measures that matrix instead of assuming it. + * + * ### What it holds + * + * `billing_address.country`, trimmed and upper-cased — nothing else. + * `countryCode` is deliberately NOT consulted: it carries ISO 3166-1 + * alpha-2, where the United Kingdom is `GB`, while the Europe rule is + * authored against `UK`. Preferring the ISO slot would silently drop UK + * accounts out of the EU territory, so this projection mirrors exactly the + * one slot the rules have always named and changes no rule semantics. + * + * Derived, never authored: `account.hook.ts` recomputes it on every write + * that carries `billing_address`, and leaves it untouched on every write + * that does not. + */ + billing_country: Field.text({ + label: 'Billing Country', + description: + 'Derived from Billing Address — the country code territory sharing rules match on. Enter the country as a 2-letter code (US, DE, …) in the address.', + readonly: true, + maxLength: 64, + group: 'contact_info', + }), + // Office Location (new field type) office_location: Field.location({ label: 'Office Location', @@ -223,6 +271,9 @@ export const Account = ObjectSchema.create({ { fields: ['name'], unique: true }, { fields: ['owner'] }, { fields: ['type', 'is_active'] }, + // The territory sharing rules filter on this column, so it is read on + // every account query a territory recipient makes (#621). + { fields: ['billing_country'] }, ], // API surface + capabilities. `trash` / `mru` were removed in @objectstack 12 diff --git a/src/sharing/account.sharing.ts b/src/sharing/account.sharing.ts index f3f83fc0..3196596b 100644 --- a/src/sharing/account.sharing.ts +++ b/src/sharing/account.sharing.ts @@ -12,14 +12,29 @@ export const AccountTeamSharingRule = { sharedWith: { type: 'position' as const, value: 'sales_manager' }, }; -/** Territory-Based Sharing (criteria-based, by billing country) */ +/** + * Territory-Based Sharing (criteria-based, by billing country). + * + * These filter on `crm_account.billing_country` — the flat projection of + * `billing_address.country` that `account.hook.ts` maintains — and NOT on the + * nested path directly. A sharing rule's condition is compiled to a + * pushdown-able filter by `compileCelToFilter`, which rejects any path reaching + * inside an `address` value; `plugin-sharing` then refuses to seed the rule at + * all rather than degrade it to match-all, which is how both of these shipped + * inert for months while the docs promised they worked (#621). + * + * The `in [...]` operator is NOT the blocker and never was — it compiles to + * `{billing_country: {$in: [...]}}` against a flat column. `test/sharing-seeding.test.ts` + * measures the whole supported operator set and fails if any declared rule + * stops translating, so this cannot silently regress again. + */ export const TerritorySharingRules = [ { name: 'north_america_territory', label: 'North America Territory', object: 'crm_account', type: 'criteria' as const, - condition: P`record.billing_address.country in ["US", "CA", "MX"]`, + condition: P`record.billing_country in ["US", "CA", "MX"]`, accessLevel: 'edit' as const, sharedWith: { type: 'position' as const, value: 'na_sales_team' }, }, @@ -28,7 +43,10 @@ export const TerritorySharingRules = [ label: 'Europe Territory', object: 'crm_account', type: 'criteria' as const, - condition: P`record.billing_address.country in ["UK", "DE", "FR", "IT", "ES"]`, + // `UK` is deliberately not `GB`: this list is carried over verbatim from + // the pre-#621 rule so the fix changes where the country is READ FROM, not + // which accounts the territory covers. + condition: P`record.billing_country in ["UK", "DE", "FR", "IT", "ES"]`, accessLevel: 'edit' as const, sharedWith: { type: 'position' as const, value: 'eu_sales_team' }, }, diff --git a/src/translations/en.ts b/src/translations/en.ts index 345b2e15..0d208a62 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -35,6 +35,10 @@ export const en: TranslationData = { phone: { label: 'Phone' }, website: { label: 'Website' }, billing_address: { label: 'Billing Address' }, + billing_country: { + label: 'Billing Country', + help: 'Derived from Billing Address — the country code territory sharing rules match on.', + }, office_location: { label: 'Office Location' }, owner: { label: 'Account Owner' }, parent_account: { label: 'Parent Account' }, diff --git a/src/translations/es-ES.ts b/src/translations/es-ES.ts index cb4bd574..5f1566eb 100644 --- a/src/translations/es-ES.ts +++ b/src/translations/es-ES.ts @@ -34,6 +34,10 @@ export const esES: TranslationData = { phone: { label: 'Teléfono' }, website: { label: 'Sitio Web' }, billing_address: { label: 'Dirección de Facturación' }, + billing_country: { + label: 'País de Facturación', + help: 'Derivado de la Dirección de Facturación — el código de país que usan las reglas de compartición por territorio.', + }, office_location: { label: 'Ubicación de Oficina' }, owner: { label: 'Propietario de Cuenta' }, parent_account: { label: 'Cuenta Matriz' }, diff --git a/src/translations/ja-JP.ts b/src/translations/ja-JP.ts index bee585c5..3501bbe2 100644 --- a/src/translations/ja-JP.ts +++ b/src/translations/ja-JP.ts @@ -34,6 +34,10 @@ export const jaJP: TranslationData = { phone: { label: '電話番号' }, website: { label: 'Webサイト' }, billing_address: { label: '請求先住所' }, + billing_country: { + label: '請求先国', + help: '請求先住所から導出 — テリトリー共有ルールが照合する国コード。', + }, office_location: { label: 'オフィス所在地' }, owner: { label: '取引先責任者' }, parent_account: { label: '親取引先' }, diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index d827a133..f1d45f94 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -35,6 +35,10 @@ export const zhCN: TranslationData = { phone: { label: '电话' }, website: { label: '网站' }, billing_address: { label: '账单地址' }, + billing_country: { + label: '账单国家', + help: '由账单地址推导——区域共享规则据此匹配的国家代码。', + }, office_location: { label: '办公地点' }, owner: { label: '客户负责人' }, parent_account: { label: '母公司' }, diff --git a/src/views/account.view.ts b/src/views/account.view.ts index 53bcd127..1a18a18c 100644 --- a/src/views/account.view.ts +++ b/src/views/account.view.ts @@ -229,7 +229,12 @@ export const AccountViews = defineView({ { label: 'Locations', columns: 1, - fields: ['billing_address', 'office_location'], + // `billing_country` is readonly and derived, but it is on the form on + // purpose: it is the value the territory sharing rules actually match, + // so an admin asking "why does the NA team not see this account?" can + // read the answer off the record instead of guessing at the address + // blob (#621). + fields: ['billing_address', 'billing_country', 'office_location'], }, { label: 'Description', diff --git a/test/action-sandbox.test.ts b/test/action-sandbox.test.ts index 0979803a..f14b542e 100644 --- a/test/action-sandbox.test.ts +++ b/test/action-sandbox.test.ts @@ -417,6 +417,47 @@ describe('every registered hook still lowers to a metadata-only body', () => { }); }); +/** + * The territory projection, executed in the VM rather than as a closure (#621). + * + * `crm_account.billing_country` is what the two territory sharing rules filter + * on, and `account_protection` is its only writer. `hooks-runtime-sales.test.ts` + * proves the logic by calling the handler directly — which cannot see the one + * failure mode that matters here: the projection is written INLINE (no + * module-scope helper) precisely so the handler still lowers to a metadata-only + * body, and a body is what the runtime actually evaluates. If a future edit + * factors it back out into a helper, the guard above turns red; if the inlined + * code uses something the sandbox does not provide, only this does. + */ +describe('account_protection projects billing_country inside the sandbox', () => { + const hook = hookNamed(allHooks.find((h) => h.name === 'account_protection'), 'account_protection'); + + it('normalises the address country onto billing_country', async () => { + const { input } = await runHookBody(hook, { + event: 'beforeInsert', + input: { name: 'Acme', billing_address: { street: '1 Main', country: ' de ' } }, + }); + expect(input.billing_country).toBe('DE'); + }); + + it('yields null for an address carrying no country', async () => { + const { input } = await runHookBody(hook, { + event: 'beforeInsert', + input: { name: 'Acme', billing_address: { city: 'Austin' } }, + }); + expect(input.billing_country).toBeNull(); + }); + + it('leaves billing_country untouched when the write omits the address', async () => { + const { input } = await runHookBody(hook, { + event: 'beforeUpdate', + input: { phone: '+1-512-555-0100' }, + previous: { billing_country: 'US' }, + }); + expect('billing_country' in input).toBe(false); + }); +}); + /** * A price fill written the way the factory's doc comment forbids: the handler * reads `objectName`, which is a closure here and nothing at all once the body diff --git a/test/hooks-runtime-sales.test.ts b/test/hooks-runtime-sales.test.ts index 0b0e4ab9..9abd708b 100644 --- a/test/hooks-runtime-sales.test.ts +++ b/test/hooks-runtime-sales.test.ts @@ -450,6 +450,77 @@ describe('account_protection', () => { })), ).resolves.toBeUndefined(); }); + + // ─── billing_country projection (#621) ─────────────────────────────── + // + // The territory sharing rules filter on `billing_country`, and this hook is + // its only writer. If the projection stops running, both rules still SEED + // (the column exists) but match nothing — the same silent territory outage + // #621 was filed for, one layer down. So the behaviour is pinned per shape. + + it.each([ + ['a country code', { country: 'US' }, 'US'], + ['lower case', { country: 'de' }, 'DE'], + ['surrounding whitespace', { country: ' fr ' }, 'FR'], + ['a full address', { street: '1 Main', city: 'Austin', country: 'US' }, 'US'], + ] as [string, Rec, string][])( + 'projects %s onto billing_country on insert', async (_label, billing_address, expected) => { + const input: Rec = { name: 'Acme', billing_address }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })); + expect(input.billing_country).toBe(expected); + }, + ); + + it.each([ + ['a null address', null], + ['an address with no country', { city: 'Austin' }], + ['a blank country', { country: ' ' }], + ['a non-string country', { country: 42 }], + ['a non-object value', 'Austin, TX'], + ] as [string, unknown][])( + 'projects %s onto null rather than throwing', async (_label, billing_address) => { + // A `before*` hook that throws rejects the whole write, so every shape an + // address column can hold must map to a value instead. + const input: Rec = { name: 'Acme', billing_address }; + await expect( + hook.handler(makeCtx({ event: 'beforeInsert', input, user: USER })), + ).resolves.toBeUndefined(); + expect(input.billing_country).toBeNull(); + }, + ); + + it('leaves billing_country alone when the write does not carry the address', async () => { + // The regression that would silently empty both territories: recomputing + // unconditionally would blank the column on every unrelated edit. + const input: Rec = { phone: '+1-512-555-0100' }; + await hook.handler(makeCtx({ + event: 'beforeUpdate', + input, + previous: { billing_address: { country: 'US' }, billing_country: 'US' }, + user: USER, + })); + expect('billing_country' in input).toBe(false); + }); + + it('clears billing_country when the address itself is cleared', async () => { + const input: Rec = { billing_address: null }; + await hook.handler(makeCtx({ + event: 'beforeUpdate', + input, + previous: { billing_address: { country: 'US' }, billing_country: 'US' }, + user: USER, + })); + expect(input.billing_country).toBeNull(); + }); + + it('projects on a SYSTEM write too — seeds and imports must land in a territory', async () => { + // Unlike `last_activity_date`, this projection is not user-gated: a seeded + // or imported account with a billing country belongs to its territory + // however it was written. + const input: Rec = { name: 'Globex', billing_address: { country: 'DE' } }; + await hook.handler(makeCtx({ event: 'beforeInsert', input, user: SYSTEM })); + expect(input.billing_country).toBe('DE'); + }); }); // ──────────────────────────────────────────────────────────── contact ── diff --git a/test/sharing-seeding.test.ts b/test/sharing-seeding.test.ts new file mode 100644 index 00000000..77b6e46f --- /dev/null +++ b/test/sharing-seeding.test.ts @@ -0,0 +1,296 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { compileCelToFilter } from '@objectstack/formula'; +import stack from '../objectstack.config'; + +/** + * Every declared sharing rule must actually be SEEDED (#621). + * + * ### The defect this file exists to catch + * + * `test/sharing-coverage.test.ts` asserts the *declared shape* of the rules — + * their labels, objects, access levels and recipients — and every one of those + * assertions was green while two of the nine rules did nothing at all: + * + * WARN [sharing-rule] skipped (missing or untranslatable CEL condition + * — never seeded as match-all) [experimental] + * {"rule":"north_america_territory", …} + * INFO [sharing-rule] declared rules seeded into sys_sharing_rule + * {"seeded":7,"skipped":2,"total":9} + * + * A declared rule is not an enforced rule. `plugin-sharing`'s + * `bootstrapDeclaredSharingRules` compiles each rule's CEL condition into a + * pushdown-able `FilterCondition` and — correctly — refuses to seed a rule it + * cannot compile, rather than degrading it to match-all. The app-side result is + * silent: a WARN in the boot stream, and two positions whose members see + * nothing while the metadata and the admin docs say they see a territory. + * + * So this file asserts the *seeded outcome*, using the platform's own + * compiler — `compileCelToFilter`, the exact function the seeder calls — rather + * than a regex over the CEL source or a re-implementation of it. A platform + * upgrade that narrows what compiles fails here, at PR time, instead of in a + * log nobody reads. + * + * ### The measured operator matrix (17.0.0-rc.1) + * + * The issue guessed the blocker was the `in [...]` membership operator. It is + * not — measured, `in [...]` compiles fine. The blocker is the NESTED PATH into + * an `address`-typed field, which is stored as one composite column: + * + * | predicate (against `crm_account`) | compiles | + * | ---------------------------------------------------------- | -------- | + * | `record.type == "customer" && record.is_active == true` | yes | + * | `record.billing_country in ["US","CA","MX"]` | yes | + * | `record.type != "customer"` | yes | + * | `record.annual_revenue > 1000` / `>=` | yes | + * | `a == x \|\| b == y` (disjunction) | yes | + * | `!(record.type == "customer")` | yes | + * | `record.type == null` | yes | + * | `record.name.startsWith("A")` | yes | + * | `record.billing_address.country == "US"` | **no** | + * | `record.billing_address.country in ["US","CA","MX"]` | **no** | + * | `has(record.type) && record.type == "customer"` | **no** | + * + * Two consequences worth carrying forward: + * + * 1. Issue #621's option A ("rewrite `in [...]` as a disjunction of `==`") + * could never have worked — the disjunction fails on the same nested path. + * Only a flat column does, which is why `crm_account.billing_country` now + * exists and `account.hook.ts` maintains it. + * 2. `has(...)` does NOT compile in a sharing condition. Adding `has()` guards + * to these rules — proposed for the flow/validation predicates in #630 and + * for sharing rules in #633 — would make every guarded rule untranslatable + * and therefore silently unseeded, i.e. it would reintroduce exactly this + * bug across all nine rules. `sharing conditions cannot use has()` below + * pins that finding so #633 cannot land the guard without seeing it. + */ + +type AnyRec = Record; + +const sharingRules: AnyRec[] = (stack as any).sharingRules ?? []; +const objects: AnyRec[] = (stack as any).objects ?? []; +const positions: AnyRec[] = (stack as any).positions ?? []; + +const objectByName = new Map(objects.map((o) => [o.name as string, o])); + +/** + * Recipient kinds `plugin-sharing`'s `mapRecipientType` maps to a real + * `sys_sharing_rule` recipient. Anything else is skipped with an "unmappable + * recipient" warning — the seeder's other silent drop. + */ +const MAPPABLE_RECIPIENTS = new Set([ + 'user', + 'team', + 'position', + 'business_unit', + 'unit_and_subordinates', +]); + +/** `P` compiles to `{ dialect: 'cel', source }`. */ +function celSource(condition: unknown): string { + if (typeof condition === 'string') return condition; + if (condition && typeof condition === 'object') return String((condition as AnyRec).source ?? ''); + return ''; +} + +/** + * Field names a compiled `FilterCondition` narrows on. + * + * Used as a strictly STRONGER stand-in for the seeder's `isMatchAllCriteria` + * check (which is internal to `plugin-sharing` and not exported): a filter that + * matches every record narrows on no field at all, so an empty result here + * implies match-all. Being stricter than the platform is safe — it can only + * ever fail a rule the platform would also have dropped, never pass one it + * would have kept. It additionally catches a rule filtering on a field that + * does not exist, which is the root cause class #621 belongs to. + */ +function narrowedFields(filter: unknown): string[] { + if (filter === null || typeof filter !== 'object') return []; + if (Array.isArray(filter)) return filter.flatMap(narrowedFields); + const out: string[] = []; + for (const [key, value] of Object.entries(filter as AnyRec)) { + if (key === '$and' || key === '$or' || key === '$nor') out.push(...narrowedFields(value)); + else if (key === '$not') out.push(...narrowedFields(value)); + else if (!key.startsWith('$')) out.push(key); + } + return [...new Set(out)]; +} + +/** What the seeder would do with one declared rule. */ +function seedOutcome(rule: AnyRec): { seeded: true; fields: string[] } | { seeded: false; why: string } { + if (!rule?.name || !rule?.object) return { seeded: false, why: 'missing name or object' }; + if (!MAPPABLE_RECIPIENTS.has(rule.sharedWith?.type) || !rule.sharedWith?.value) { + return { seeded: false, why: `unmappable recipient "${rule.sharedWith?.type}"` }; + } + if (rule.type === 'owner') return { seeded: false, why: 'owner-based rule (retired shape)' }; + const result = compileCelToFilter(rule.condition ?? '', { variables: {} }); + if (!result.ok) { + return { seeded: false, why: `untranslatable condition (${result.reason}: ${result.detail})` }; + } + const fields = narrowedFields(result.filter); + if (fields.length === 0) { + return { seeded: false, why: 'condition narrows on no field — would share every record' }; + } + return { seeded: true, fields }; +} + +describe('every declared sharing rule is actually seeded', () => { + it('finds rules to check at all', () => { + // Guard the guard: a config refactor that stopped exposing `sharingRules` + // would turn every assertion below into a vacuous pass. + expect(sharingRules.length).toBeGreaterThanOrEqual(9); + expect(sharingRules.some((r) => r.name === 'north_america_territory')).toBe(true); + expect(sharingRules.some((r) => r.name === 'europe_territory')).toBe(true); + }); + + it('seeds all of them — seeded + 0 skipped', () => { + const skipped = sharingRules + .map((rule) => ({ name: rule.name as string, outcome: seedOutcome(rule) })) + .filter((r) => !r.outcome.seeded) + .map((r) => `${r.name}: ${(r.outcome as { why: string }).why}`); + + expect( + skipped, + 'These rules are DECLARED but would be dropped at boot, so the positions they name ' + + 'receive nothing while the metadata and the admin docs say they do. plugin-sharing ' + + 'refuses to seed a rule it cannot compile rather than degrade it to match-all — the ' + + 'fix belongs in the rule (or in the object it filters on), never in the platform:\n ' + + skipped.join('\n '), + ).toEqual([]); + + // The counts the boot log prints, asserted directly. + const seeded = sharingRules.filter((rule) => seedOutcome(rule).seeded).length; + expect(seeded).toBe(sharingRules.length); + }); + + it('every field a rule filters on is a real, flat column of its object', () => { + // The #621 root cause stated positively: a sharing condition may only name + // fields that exist as columns. A nested path into a composite value + // (`address`, `location`) is not one, and neither is a typo. + const bad: string[] = []; + for (const rule of sharingRules) { + const object = objectByName.get(rule.object as string); + if (!object) { + bad.push(`${rule.name}: targets unknown object "${rule.object}"`); + continue; + } + const outcome = seedOutcome(rule); + if (!outcome.seeded) continue; // reported by the test above + for (const field of outcome.fields) { + if (!(field in (object.fields ?? {}))) { + bad.push(`${rule.name}: filters on "${field}", which ${rule.object} does not declare`); + } + } + } + expect(bad, `sharing rules filtering on fields that do not exist:\n ${bad.join('\n ')}`).toEqual([]); + }); + + it('every rule hands its records to a position the app actually ships', () => { + // A rule granted to a position nobody declares is inert in a second, + // quieter way — it seeds fine and expands to an empty recipient set. + const declared = new Set(positions.map((p) => p.name as string)); + const bad = sharingRules + .filter((r) => r.sharedWith?.type === 'position' && !declared.has(r.sharedWith?.value)) + .map((r) => `${r.name}: grants to undeclared position "${r.sharedWith?.value}"`); + expect(bad, `rules granting to positions that do not exist:\n ${bad.join('\n ')}`).toEqual([]); + }); +}); + +describe('the territory rules read the flat projection, not the address blob', () => { + const territoryRules = sharingRules.filter((r) => + ['north_america_territory', 'europe_territory'].includes(r.name as string), + ); + + it('both territory rules exist', () => { + expect(territoryRules).toHaveLength(2); + }); + + it.each(territoryRules.map((r) => [r.name as string, r] as const))( + '%s filters on billing_country', + (_name, rule) => { + const outcome = seedOutcome(rule); + expect(outcome.seeded, `not seeded: ${JSON.stringify(outcome)}`).toBe(true); + expect((outcome as { fields: string[] }).fields).toEqual(['billing_country']); + }, + ); + + it.each(territoryRules.map((r) => [r.name as string, r] as const))( + '%s reaches into no composite value', + (_name, rule) => { + // `record.billing_address.country` is the exact shape that shipped inert. + expect(celSource(rule.condition)).not.toMatch(/record\.\w+\.\w+/); + }, + ); + + it('keeps the territories it always covered', () => { + // The fix moved WHERE the country is read from; it must not quietly move + // WHICH accounts each team gets. `UK` (not the ISO `GB`) is carried over + // deliberately — see the note in src/sharing/account.sharing.ts. + const na = territoryRules.find((r) => r.name === 'north_america_territory'); + const eu = territoryRules.find((r) => r.name === 'europe_territory'); + expect(celSource(na!.condition)).toBe('record.billing_country in ["US", "CA", "MX"]'); + expect(celSource(eu!.condition)).toBe('record.billing_country in ["UK", "DE", "FR", "IT", "ES"]'); + }); + + it('crm_account carries billing_country as a readonly, derived column', () => { + const account = objectByName.get('crm_account'); + const field = (account?.fields ?? {}).billing_country; + expect(field, 'crm_account.billing_country is gone — the territory rules now filter on nothing').toBeDefined(); + expect(field.type).toBe('text'); + // Readonly is what makes it a projection rather than a second place to + // type a country: `account.hook.ts` is the only writer. + expect(field.readonly).toBe(true); + }); +}); + +/** + * The operator matrix, measured rather than remembered. + * + * These are not assertions about HotCRM metadata — they pin what the PLATFORM's + * sharing-rule compiler accepts, so the reasoning recorded at the top of this + * file stays checkable. If a platform upgrade widens support for nested paths, + * `nested paths into a composite value do not compile` fails and + * `billing_country` can be reconsidered; if it narrows, the rules break here + * rather than in a boot log. + */ +describe('what a sharing condition may contain (platform compiler, measured)', () => { + const compiles = (source: string) => compileCelToFilter(source, { variables: {} }).ok; + + it.each([ + ['equality + conjunction', 'record.type == "customer" && record.is_active == true'], + ['membership on a flat field', 'record.billing_country in ["US", "CA", "MX"]'], + ['inequality', 'record.type != "customer"'], + ['ordering', 'record.annual_revenue >= 1000'], + ['disjunction', 'record.billing_country == "US" || record.billing_country == "CA"'], + ['negation', '!(record.type == "customer")'], + ['null comparison', 'record.type == null'], + ['string predicate', 'record.name.startsWith("A")'], + ])('compiles: %s', (_label, source) => { + expect(compiles(source)).toBe(true); + }); + + it('nested paths into a composite value do not compile', () => { + // The #621 blocker, isolated: same operator, flat field vs nested path. + expect(compiles('record.billing_country in ["US", "CA", "MX"]')).toBe(true); + expect(compiles('record.billing_address.country in ["US", "CA", "MX"]')).toBe(false); + // …and it is the PATH, not the operator: `==` fails on it too, which is + // why issue #621's option A (a disjunction of `==`) was never viable. + expect(compiles('record.billing_address.country == "US"')).toBe(false); + expect( + compiles( + 'record.billing_address.country == "US" || record.billing_address.country == "CA"', + ), + ).toBe(false); + }); + + it('sharing conditions cannot use has()', () => { + // Load-bearing for #633: a `has()` guard added to any of these rules would + // make it untranslatable, and plugin-sharing would silently stop seeding + // it — the #621 failure, reintroduced across every guarded rule. The + // fail-open/fail-closed question #633 asks therefore cannot be answered + // with the same `has()` shape that #630 used for validation predicates. + expect(compiles('record.type == "customer"')).toBe(true); + expect(compiles('has(record.type) && record.type == "customer"')).toBe(false); + }); +}); From 67e5f84d410a3e2779eb8a30fc6d70b235d08d8d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 15:39:26 +0000 Subject: [PATCH 2/2] chore: regenerate package-lock.json for the @objectstack/formula devDependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `package-lock.json` is maintained separately from `pnpm-lock.yaml` for the StackBlitz demo (WebContainers cannot run this repo's pnpm), and `scripts/check-stackblitz-lock.mjs` compares its root entry against package.json. Adding `@objectstack/formula` to devDependencies made it stale. Regenerated with the exact command the gate prints: d=$(mktemp -d) && cp package.json "$d" \ && (cd "$d" && npm install --package-lock-only --ignore-scripts) \ && cp "$d/package-lock.json" . The root `devDependencies` delta is exactly the intended addition. The command also re-resolves every floating `^` range against the registry, so ten transitive entries moved to newer patch/minor versions (@babel/parser and @babel/types 7.29.7 to 7.29.8, @napi-rs/wasm-runtime, jose, js-yaml x3, magicast, tinyexec, tsx). No entry was added or removed, and no @objectstack/* package moved. That churn is inherent to the documented regeneration step — the script's own header notes a regenerated lockfile "shifts with the npm version", which is why it compares the root entry rather than diffing the file — so it is left as generated rather than hand-trimmed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf --- package-lock.json | 67 ++++++++++++++++++++++++----------------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/package-lock.json b/package-lock.json index 381e276d..dd4edcda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@changesets/cli": "^2.31.1", + "@objectstack/formula": "17.0.0-rc.1", "@playwright/test": "^1.61.1", "@vitest/coverage-v8": "^4.1.10", "tsx": "^4.23.1", @@ -81,13 +82,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -107,9 +108,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -1241,9 +1242,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz", - "integrity": "sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, @@ -1709,9 +1710,9 @@ } }, "node_modules/@objectstack/metadata/node_modules/js-yaml": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", - "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", + "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", "funding": [ { "type": "github", @@ -5532,9 +5533,9 @@ } }, "node_modules/jose": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.5.tgz", - "integrity": "sha512-2E5L2yRp03FnwreJLJX8/r7mHiZICCf8kG7fAsTWkSQTDAcc46NIZoQLKy+EJ8sPoJlxyS4OQR5H70LjIZZlIQ==", + "version": "6.2.7", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.7.tgz", + "integrity": "sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -5548,9 +5549,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -6196,14 +6197,14 @@ } }, "node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, @@ -7179,9 +7180,9 @@ } }, "node_modules/read-yaml-file/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -8040,9 +8041,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "devOptional": true, "license": "MIT", "engines": { @@ -8199,9 +8200,9 @@ "optional": true }, "node_modules/tsx": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", - "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "version": "4.23.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.4.tgz", + "integrity": "sha512-ZiUQ8oT/KzN51mJUWPqARYqwFLFJZtGZipRkw1ynHMr9vy3eU77m5yfF3Gzm6meEg/beW+lUu3fHYgskTN2oVQ==", "license": "MIT", "dependencies": { "esbuild": "~0.28.0"