@open-mercato/shared 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6785.1.1dd7cfac55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +18 -3
  3. package/dist/lib/ai/safety-identifier.js +32 -0
  4. package/dist/lib/ai/safety-identifier.js.map +7 -0
  5. package/dist/lib/commands/command-bus.js +5 -1
  6. package/dist/lib/commands/command-bus.js.map +2 -2
  7. package/dist/lib/commands/command-interceptor-runner.js +2 -2
  8. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  9. package/dist/lib/crud/enricher-runner.js +2 -11
  10. package/dist/lib/crud/enricher-runner.js.map +2 -2
  11. package/dist/lib/crud/factory.js +4 -2
  12. package/dist/lib/crud/factory.js.map +2 -2
  13. package/dist/lib/crud/interceptor-runner.js +2 -2
  14. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  15. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  16. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  17. package/dist/lib/crud/types.js +4 -0
  18. package/dist/lib/crud/types.js.map +3 -3
  19. package/dist/lib/data/consistency.js +19 -0
  20. package/dist/lib/data/consistency.js.map +7 -0
  21. package/dist/lib/data/engine.js +37 -11
  22. package/dist/lib/data/engine.js.map +2 -2
  23. package/dist/lib/version.js +1 -1
  24. package/dist/lib/version.js.map +1 -1
  25. package/dist/security/enabledModulesRegistry.js +56 -11
  26. package/dist/security/enabledModulesRegistry.js.map +2 -2
  27. package/dist/security/featurePolicy.js +62 -0
  28. package/dist/security/featurePolicy.js.map +7 -0
  29. package/package.json +6 -2
  30. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  31. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  32. package/src/lib/ai/llm-provider.ts +33 -0
  33. package/src/lib/ai/safety-identifier.ts +73 -0
  34. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
  35. package/src/lib/commands/command-bus.ts +5 -1
  36. package/src/lib/commands/command-interceptor-runner.ts +2 -2
  37. package/src/lib/crud/__tests__/crud-factory.test.ts +18 -0
  38. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  39. package/src/lib/crud/enricher-runner.ts +2 -11
  40. package/src/lib/crud/factory.ts +2 -0
  41. package/src/lib/crud/interceptor-runner.ts +2 -2
  42. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  43. package/src/lib/crud/types.ts +3 -0
  44. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  45. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  46. package/src/lib/data/consistency.ts +17 -0
  47. package/src/lib/data/engine.ts +50 -16
  48. package/src/modules/customer-auth.ts +1 -0
  49. package/src/modules/navigation/backendChrome.ts +1 -0
  50. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  51. package/src/security/enabledModulesRegistry.ts +64 -12
  52. package/src/security/featurePolicy.ts +89 -0
@@ -6,21 +6,57 @@ function buildRegistry(modules) {
6
6
  const enabledModuleSet = new Set(enabledModuleIds);
7
7
  const featureToModule = /* @__PURE__ */ new Map();
8
8
  const prefixToModule = /* @__PURE__ */ new Map();
9
+ const concreteFeatureIds = [];
10
+ const concreteFeatureSet = /* @__PURE__ */ new Set();
11
+ const addConcreteFeature = (featureId, owningModule, authoritative) => {
12
+ if (!featureId || featureId === "*" || featureId.endsWith(".*")) return;
13
+ if (!concreteFeatureSet.has(featureId)) {
14
+ concreteFeatureSet.add(featureId);
15
+ concreteFeatureIds.push(featureId);
16
+ }
17
+ if (authoritative || !featureToModule.has(featureId)) {
18
+ featureToModule.set(featureId, owningModule);
19
+ }
20
+ const dot = featureId.indexOf(".");
21
+ if (dot > 0) {
22
+ const prefix = featureId.slice(0, dot);
23
+ if (!prefixToModule.has(prefix)) prefixToModule.set(prefix, owningModule);
24
+ }
25
+ };
9
26
  for (const mod of modules) {
10
27
  const features = mod.features;
11
- if (!Array.isArray(features)) continue;
12
- for (const feature of features) {
13
- if (!feature || typeof feature.id !== "string" || !feature.id) continue;
14
- const declared = typeof feature.module === "string" && feature.module.length > 0 ? feature.module : mod.id;
15
- featureToModule.set(feature.id, declared);
16
- const dot = feature.id.indexOf(".");
17
- if (dot > 0) {
18
- const prefix = feature.id.slice(0, dot);
19
- if (!prefixToModule.has(prefix)) prefixToModule.set(prefix, declared);
28
+ if (Array.isArray(features)) {
29
+ for (const feature of features) {
30
+ if (!feature || typeof feature.id !== "string" || !feature.id) continue;
31
+ const declared = typeof feature.module === "string" && feature.module.length > 0 ? feature.module : mod.id;
32
+ addConcreteFeature(feature.id, declared, true);
33
+ }
34
+ }
35
+ const customerDefaults = mod.setup?.defaultCustomerRoleFeatures;
36
+ if (customerDefaults) {
37
+ for (const roleFeatures of Object.values(customerDefaults)) {
38
+ if (!Array.isArray(roleFeatures)) continue;
39
+ for (const featureId of roleFeatures) {
40
+ if (typeof featureId === "string") addConcreteFeature(featureId, mod.id, false);
41
+ }
42
+ }
43
+ }
44
+ if (Array.isArray(mod.frontendRoutes)) {
45
+ for (const route of mod.frontendRoutes) {
46
+ if (!Array.isArray(route.requireCustomerFeatures)) continue;
47
+ for (const featureId of route.requireCustomerFeatures) {
48
+ if (typeof featureId === "string") addConcreteFeature(featureId, mod.id, false);
49
+ }
20
50
  }
21
51
  }
22
52
  }
23
- return { enabledModuleIds, enabledModuleSet, featureToModule, prefixToModule };
53
+ return {
54
+ enabledModuleIds,
55
+ enabledModuleSet,
56
+ featureToModule,
57
+ prefixToModule,
58
+ concreteFeatureIds
59
+ };
24
60
  }
25
61
  function getRegistry() {
26
62
  try {
@@ -52,6 +88,13 @@ function getEnabledModuleIds() {
52
88
  const registry = getRegistry();
53
89
  return registry ? [...registry.enabledModuleIds] : [];
54
90
  }
91
+ function getConcreteFeatureIds() {
92
+ const registry = getRegistry();
93
+ return registry ? [...registry.concreteFeatureIds] : [];
94
+ }
95
+ function hasEnabledModulesRegistry() {
96
+ return getRegistry() !== null;
97
+ }
55
98
  function filterGrantsByEnabledModules(granted) {
56
99
  const registry = getRegistry();
57
100
  if (!registry) return [...granted];
@@ -73,7 +116,9 @@ function filterGrantsByEnabledModules(granted) {
73
116
  }
74
117
  export {
75
118
  filterGrantsByEnabledModules,
119
+ getConcreteFeatureIds,
76
120
  getEnabledModuleIds,
77
- getOwningModuleId
121
+ getOwningModuleId,
122
+ hasEnabledModulesRegistry
78
123
  };
79
124
  //# sourceMappingURL=enabledModulesRegistry.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/security/enabledModulesRegistry.ts"],
4
- "sourcesContent": ["/**\n * Module-aware grant filtering.\n *\n * Features live under `<module>.<action>` (see AGENTS.md naming convention).\n * When a module is disabled in `modules.ts`, its routes/UI are absent but\n * roles may still carry the feature string. Anywhere we turn raw ACL\n * grants into \"what the user can currently act on\", we must drop grants\n * whose owning module is not enabled \u2014 otherwise stale grants re-open the\n * 404-class bug PR #1567 only partially fixed.\n *\n * Owning-module resolution:\n * - Most features follow the convention so `id.split('.')[0]` matches the\n * module id. For those, the prefix is correct.\n * - A few features (e.g. `analytics.view`) deliberately use a different\n * namespace from their owning module. For those, the registry's declared\n * `module` field on the `Module.features` entry is authoritative. We\n * consult it first and fall back to the prefix only when the feature is\n * unknown to the registry.\n *\n * This helper is server-only: it reads the enabled module set from the\n * bootstrapped module registry. The browser never imports it; instead,\n * server code pre-filters `BackendChromePayload.grantedFeatures` so\n * client-side `hasFeature` can stay a pure grant check.\n */\n\nimport { getModules } from '../lib/modules/registry'\nimport type { Module } from '../modules/registry'\n\ntype FeatureRegistry = {\n enabledModuleIds: string[]\n enabledModuleSet: Set<string>\n featureToModule: Map<string, string>\n prefixToModule: Map<string, string>\n}\n\nlet cachedRegistry: FeatureRegistry | null = null\nlet cachedModulesRef: readonly Module[] | null = null\n\nfunction buildRegistry(modules: readonly Module[]): FeatureRegistry {\n const enabledModuleIds = modules.map((mod) => mod.id)\n const enabledModuleSet = new Set(enabledModuleIds)\n const featureToModule = new Map<string, string>()\n const prefixToModule = new Map<string, string>()\n for (const mod of modules) {\n const features = mod.features\n if (!Array.isArray(features)) continue\n for (const feature of features) {\n if (!feature || typeof feature.id !== 'string' || !feature.id) continue\n const declared = typeof feature.module === 'string' && feature.module.length > 0\n ? feature.module\n : mod.id\n featureToModule.set(feature.id, declared)\n const dot = feature.id.indexOf('.')\n if (dot > 0) {\n const prefix = feature.id.slice(0, dot)\n if (!prefixToModule.has(prefix)) prefixToModule.set(prefix, declared)\n }\n }\n }\n return { enabledModuleIds, enabledModuleSet, featureToModule, prefixToModule }\n}\n\nfunction getRegistry(): FeatureRegistry | null {\n try {\n const modules = getModules() as readonly Module[]\n if (cachedRegistry && cachedModulesRef === modules) return cachedRegistry\n cachedModulesRef = modules\n cachedRegistry = buildRegistry(modules)\n return cachedRegistry\n } catch {\n return null\n }\n}\n\nexport function getOwningModuleId(featureId: string): string {\n const registry = getRegistry()\n if (registry) {\n const direct = registry.featureToModule.get(featureId)\n if (direct) return direct\n if (featureId.endsWith('.*')) {\n const prefix = featureId.slice(0, -2)\n const fromPrefix = registry.prefixToModule.get(prefix)\n if (fromPrefix) return fromPrefix\n return prefix\n }\n }\n const dot = featureId.indexOf('.')\n return dot === -1 ? featureId : featureId.slice(0, dot)\n}\n\nexport function getEnabledModuleIds(): string[] {\n const registry = getRegistry()\n return registry ? [...registry.enabledModuleIds] : []\n}\n\n/**\n * Filters a raw granted-features list down to the grants whose owning\n * module is currently enabled. Expands `*` (superadmin) into one wildcard\n * per enabled module so the result is still safe to feed into a pure\n * `matchFeature` check, plus one wildcard per off-convention feature\n * prefix (e.g. `analytics.*`) whose declared owning module is enabled.\n * If the module registry is not populated (tests, CLI), returns the input\n * unchanged \u2014 preserves legacy behavior.\n */\nexport function filterGrantsByEnabledModules(granted: readonly string[]): string[] {\n const registry = getRegistry()\n if (!registry) return [...granted]\n const { enabledModuleIds, enabledModuleSet, prefixToModule } = registry\n const result: string[] = []\n for (const grant of granted) {\n if (grant === '*') {\n for (const id of enabledModuleIds) result.push(`${id}.*`)\n for (const [prefix, owningModule] of prefixToModule) {\n if (!enabledModuleSet.has(prefix) && enabledModuleSet.has(owningModule)) {\n result.push(`${prefix}.*`)\n }\n }\n continue\n }\n if (enabledModuleSet.has(getOwningModuleId(grant))) result.push(grant)\n }\n return result\n}\n"],
5
- "mappings": "AAyBA,SAAS,kBAAkB;AAU3B,IAAI,iBAAyC;AAC7C,IAAI,mBAA6C;AAEjD,SAAS,cAAc,SAA6C;AAClE,QAAM,mBAAmB,QAAQ,IAAI,CAAC,QAAQ,IAAI,EAAE;AACpD,QAAM,mBAAmB,IAAI,IAAI,gBAAgB;AACjD,QAAM,kBAAkB,oBAAI,IAAoB;AAChD,QAAM,iBAAiB,oBAAI,IAAoB;AAC/C,aAAW,OAAO,SAAS;AACzB,UAAM,WAAW,IAAI;AACrB,QAAI,CAAC,MAAM,QAAQ,QAAQ,EAAG;AAC9B,eAAW,WAAW,UAAU;AAC9B,UAAI,CAAC,WAAW,OAAO,QAAQ,OAAO,YAAY,CAAC,QAAQ,GAAI;AAC/D,YAAM,WAAW,OAAO,QAAQ,WAAW,YAAY,QAAQ,OAAO,SAAS,IAC3E,QAAQ,SACR,IAAI;AACR,sBAAgB,IAAI,QAAQ,IAAI,QAAQ;AACxC,YAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG;AAClC,UAAI,MAAM,GAAG;AACX,cAAM,SAAS,QAAQ,GAAG,MAAM,GAAG,GAAG;AACtC,YAAI,CAAC,eAAe,IAAI,MAAM,EAAG,gBAAe,IAAI,QAAQ,QAAQ;AAAA,MACtE;AAAA,IACF;AAAA,EACF;AACA,SAAO,EAAE,kBAAkB,kBAAkB,iBAAiB,eAAe;AAC/E;AAEA,SAAS,cAAsC;AAC7C,MAAI;AACF,UAAM,UAAU,WAAW;AAC3B,QAAI,kBAAkB,qBAAqB,QAAS,QAAO;AAC3D,uBAAmB;AACnB,qBAAiB,cAAc,OAAO;AACtC,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,kBAAkB,WAA2B;AAC3D,QAAM,WAAW,YAAY;AAC7B,MAAI,UAAU;AACZ,UAAM,SAAS,SAAS,gBAAgB,IAAI,SAAS;AACrD,QAAI,OAAQ,QAAO;AACnB,QAAI,UAAU,SAAS,IAAI,GAAG;AAC5B,YAAM,SAAS,UAAU,MAAM,GAAG,EAAE;AACpC,YAAM,aAAa,SAAS,eAAe,IAAI,MAAM;AACrD,UAAI,WAAY,QAAO;AACvB,aAAO;AAAA,IACT;AAAA,EACF;AACA,QAAM,MAAM,UAAU,QAAQ,GAAG;AACjC,SAAO,QAAQ,KAAK,YAAY,UAAU,MAAM,GAAG,GAAG;AACxD;AAEO,SAAS,sBAAgC;AAC9C,QAAM,WAAW,YAAY;AAC7B,SAAO,WAAW,CAAC,GAAG,SAAS,gBAAgB,IAAI,CAAC;AACtD;AAWO,SAAS,6BAA6B,SAAsC;AACjF,QAAM,WAAW,YAAY;AAC7B,MAAI,CAAC,SAAU,QAAO,CAAC,GAAG,OAAO;AACjC,QAAM,EAAE,kBAAkB,kBAAkB,eAAe,IAAI;AAC/D,QAAM,SAAmB,CAAC;AAC1B,aAAW,SAAS,SAAS;AAC3B,QAAI,UAAU,KAAK;AACjB,iBAAW,MAAM,iBAAkB,QAAO,KAAK,GAAG,EAAE,IAAI;AACxD,iBAAW,CAAC,QAAQ,YAAY,KAAK,gBAAgB;AACnD,YAAI,CAAC,iBAAiB,IAAI,MAAM,KAAK,iBAAiB,IAAI,YAAY,GAAG;AACvE,iBAAO,KAAK,GAAG,MAAM,IAAI;AAAA,QAC3B;AAAA,MACF;AACA;AAAA,IACF;AACA,QAAI,iBAAiB,IAAI,kBAAkB,KAAK,CAAC,EAAG,QAAO,KAAK,KAAK;AAAA,EACvE;AACA,SAAO;AACT;",
4
+ "sourcesContent": ["/**\n * Module-aware grant filtering.\n *\n * Features live under `<module>.<action>` (see AGENTS.md naming convention).\n * When a module is disabled in `modules.ts`, its routes/UI are absent but\n * roles may still carry the feature string. Anywhere we turn raw ACL\n * grants into \"what the user can currently act on\", we must drop grants\n * whose owning module is not enabled \u2014 otherwise stale grants re-open the\n * 404-class bug PR #1567 only partially fixed.\n *\n * Owning-module resolution:\n * - Most features follow the convention so `id.split('.')[0]` matches the\n * module id. For those, the prefix is correct.\n * - A few features (e.g. `analytics.view`) deliberately use a different\n * namespace from their owning module. For those, the registry's declared\n * `module` field on the `Module.features` entry is authoritative. We\n * consult it first and fall back to the prefix only when the feature is\n * unknown to the registry.\n *\n * This helper is server-only: it reads the enabled module set from the\n * bootstrapped module registry. The browser never imports it; instead,\n * server code pre-filters `BackendChromePayload.grantedFeatures` so\n * client-side `hasFeature` can stay a pure grant check.\n */\n\nimport { getModules } from '../lib/modules/registry'\nimport type { Module } from '../modules/registry'\n\ntype FeatureRegistry = {\n enabledModuleIds: string[]\n enabledModuleSet: Set<string>\n featureToModule: Map<string, string>\n prefixToModule: Map<string, string>\n concreteFeatureIds: string[]\n}\n\nlet cachedRegistry: FeatureRegistry | null = null\nlet cachedModulesRef: readonly Module[] | null = null\n\nfunction buildRegistry(modules: readonly Module[]): FeatureRegistry {\n const enabledModuleIds = modules.map((mod) => mod.id)\n const enabledModuleSet = new Set(enabledModuleIds)\n const featureToModule = new Map<string, string>()\n const prefixToModule = new Map<string, string>()\n const concreteFeatureIds: string[] = []\n const concreteFeatureSet = new Set<string>()\n\n const addConcreteFeature = (featureId: string, owningModule: string, authoritative: boolean) => {\n if (!featureId || featureId === '*' || featureId.endsWith('.*')) return\n if (!concreteFeatureSet.has(featureId)) {\n concreteFeatureSet.add(featureId)\n concreteFeatureIds.push(featureId)\n }\n if (authoritative || !featureToModule.has(featureId)) {\n featureToModule.set(featureId, owningModule)\n }\n const dot = featureId.indexOf('.')\n if (dot > 0) {\n const prefix = featureId.slice(0, dot)\n if (!prefixToModule.has(prefix)) prefixToModule.set(prefix, owningModule)\n }\n }\n\n for (const mod of modules) {\n const features = mod.features\n if (Array.isArray(features)) {\n for (const feature of features) {\n if (!feature || typeof feature.id !== 'string' || !feature.id) continue\n const declared = typeof feature.module === 'string' && feature.module.length > 0\n ? feature.module\n : mod.id\n addConcreteFeature(feature.id, declared, true)\n }\n }\n\n const customerDefaults = mod.setup?.defaultCustomerRoleFeatures\n if (customerDefaults) {\n for (const roleFeatures of Object.values(customerDefaults)) {\n if (!Array.isArray(roleFeatures)) continue\n for (const featureId of roleFeatures) {\n if (typeof featureId === 'string') addConcreteFeature(featureId, mod.id, false)\n }\n }\n }\n\n if (Array.isArray(mod.frontendRoutes)) {\n for (const route of mod.frontendRoutes) {\n if (!Array.isArray(route.requireCustomerFeatures)) continue\n for (const featureId of route.requireCustomerFeatures) {\n if (typeof featureId === 'string') addConcreteFeature(featureId, mod.id, false)\n }\n }\n }\n }\n return {\n enabledModuleIds,\n enabledModuleSet,\n featureToModule,\n prefixToModule,\n concreteFeatureIds,\n }\n}\n\nfunction getRegistry(): FeatureRegistry | null {\n try {\n const modules = getModules() as readonly Module[]\n if (cachedRegistry && cachedModulesRef === modules) return cachedRegistry\n cachedModulesRef = modules\n cachedRegistry = buildRegistry(modules)\n return cachedRegistry\n } catch {\n return null\n }\n}\n\nexport function getOwningModuleId(featureId: string): string {\n const registry = getRegistry()\n if (registry) {\n const direct = registry.featureToModule.get(featureId)\n if (direct) return direct\n if (featureId.endsWith('.*')) {\n const prefix = featureId.slice(0, -2)\n const fromPrefix = registry.prefixToModule.get(prefix)\n if (fromPrefix) return fromPrefix\n return prefix\n }\n }\n const dot = featureId.indexOf('.')\n return dot === -1 ? featureId : featureId.slice(0, dot)\n}\n\nexport function getEnabledModuleIds(): string[] {\n const registry = getRegistry()\n return registry ? [...registry.enabledModuleIds] : []\n}\n\n/** @internal Infrastructure input for concrete feature-policy projection. */\nexport function getConcreteFeatureIds(): string[] {\n const registry = getRegistry()\n return registry ? [...registry.concreteFeatureIds] : []\n}\n\n/** @internal Distinguishes an empty registry from an unavailable bootstrap registry. */\nexport function hasEnabledModulesRegistry(): boolean {\n return getRegistry() !== null\n}\n\n/**\n * Filters a raw granted-features list down to the grants whose owning\n * module is currently enabled. Expands `*` (superadmin) into one wildcard\n * per enabled module so the result is still safe to feed into a pure\n * `matchFeature` check, plus one wildcard per off-convention feature\n * prefix (e.g. `analytics.*`) whose declared owning module is enabled.\n * If the module registry is not populated (tests, CLI), returns the input\n * unchanged \u2014 preserves legacy behavior.\n */\nexport function filterGrantsByEnabledModules(granted: readonly string[]): string[] {\n const registry = getRegistry()\n if (!registry) return [...granted]\n const { enabledModuleIds, enabledModuleSet, prefixToModule } = registry\n const result: string[] = []\n for (const grant of granted) {\n if (grant === '*') {\n for (const id of enabledModuleIds) result.push(`${id}.*`)\n for (const [prefix, owningModule] of prefixToModule) {\n if (!enabledModuleSet.has(prefix) && enabledModuleSet.has(owningModule)) {\n result.push(`${prefix}.*`)\n }\n }\n continue\n }\n if (enabledModuleSet.has(getOwningModuleId(grant))) result.push(grant)\n }\n return result\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,62 @@
1
+ import { hasAllFeatures as matchesAllFeatures } from "./features.js";
2
+ import {
3
+ filterGrantsByEnabledModules,
4
+ getConcreteFeatureIds,
5
+ getEnabledModuleIds,
6
+ getOwningModuleId,
7
+ hasEnabledModulesRegistry
8
+ } from "./enabledModulesRegistry.js";
9
+ import { composeAclFeatureOverrides } from "../modules/overrides.js";
10
+ function getRemovedAclFeatureIds() {
11
+ return Object.entries(composeAclFeatureOverrides()).filter(([, override]) => override === null).map(([featureId]) => featureId);
12
+ }
13
+ function isAclFeatureRemoved(featureId) {
14
+ return composeAclFeatureOverrides()[featureId] === null;
15
+ }
16
+ function isFeatureEnabled(featureId) {
17
+ if (!hasEnabledModulesRegistry()) return true;
18
+ const enabledModuleIds = getEnabledModuleIds();
19
+ return enabledModuleIds.includes(getOwningModuleId(featureId));
20
+ }
21
+ function authorizeFeatures(required, subject) {
22
+ if (required.length === 0) return true;
23
+ if (subject.scopeAllowed === false) return false;
24
+ if (required.some((featureId) => isAclFeatureRemoved(featureId) || !isFeatureEnabled(featureId))) {
25
+ return false;
26
+ }
27
+ if (subject.unrestricted === true) return true;
28
+ return matchesAllFeatures(
29
+ filterGrantsByEnabledModules(subject.grantedFeatures),
30
+ required
31
+ );
32
+ }
33
+ function resolveEffectiveFeatures(grantedFeatures) {
34
+ const filteredGrants = filterGrantsByEnabledModules(grantedFeatures).filter((featureId) => !isAclFeatureRemoved(featureId));
35
+ if (!hasEnabledModulesRegistry()) {
36
+ return filteredGrants.filter((featureId, index, features) => featureId !== "*" && !featureId.endsWith(".*") && features.indexOf(featureId) === index);
37
+ }
38
+ const result = [];
39
+ const seen = /* @__PURE__ */ new Set();
40
+ const addFeature = (featureId) => {
41
+ if (seen.has(featureId) || isAclFeatureRemoved(featureId) || !isFeatureEnabled(featureId)) {
42
+ return;
43
+ }
44
+ seen.add(featureId);
45
+ result.push(featureId);
46
+ };
47
+ for (const featureId of getConcreteFeatureIds()) {
48
+ if (matchesAllFeatures(filteredGrants, [featureId])) addFeature(featureId);
49
+ }
50
+ for (const featureId of filteredGrants) {
51
+ if (featureId === "*" || featureId.endsWith(".*")) continue;
52
+ addFeature(featureId);
53
+ }
54
+ return result;
55
+ }
56
+ export {
57
+ authorizeFeatures,
58
+ getRemovedAclFeatureIds,
59
+ isAclFeatureRemoved,
60
+ resolveEffectiveFeatures
61
+ };
62
+ //# sourceMappingURL=featurePolicy.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/security/featurePolicy.ts"],
4
+ "sourcesContent": ["import { hasAllFeatures as matchesAllFeatures } from './features'\nimport {\n filterGrantsByEnabledModules,\n getConcreteFeatureIds,\n getEnabledModuleIds,\n getOwningModuleId,\n hasEnabledModulesRegistry,\n} from './enabledModulesRegistry'\nimport { composeAclFeatureOverrides } from '../modules/overrides'\n\nexport type FeaturePolicySubject = {\n grantedFeatures: readonly string[]\n unrestricted?: boolean\n scopeAllowed?: boolean\n}\n\nexport function getRemovedAclFeatureIds(): string[] {\n return Object.entries(composeAclFeatureOverrides())\n .filter(([, override]) => override === null)\n .map(([featureId]) => featureId)\n}\n\nexport function isAclFeatureRemoved(featureId: string): boolean {\n return composeAclFeatureOverrides()[featureId] === null\n}\n\nfunction isFeatureEnabled(featureId: string): boolean {\n if (!hasEnabledModulesRegistry()) return true\n const enabledModuleIds = getEnabledModuleIds()\n return enabledModuleIds.includes(getOwningModuleId(featureId))\n}\n\nexport function authorizeFeatures(\n required: readonly string[],\n subject: FeaturePolicySubject,\n): boolean {\n if (required.length === 0) return true\n if (subject.scopeAllowed === false) return false\n if (required.some((featureId) => (\n isAclFeatureRemoved(featureId) || !isFeatureEnabled(featureId)\n ))) {\n return false\n }\n if (subject.unrestricted === true) return true\n return matchesAllFeatures(\n filterGrantsByEnabledModules(subject.grantedFeatures),\n required,\n )\n}\n\nexport function resolveEffectiveFeatures(\n grantedFeatures: readonly string[],\n): string[] {\n const filteredGrants = filterGrantsByEnabledModules(grantedFeatures)\n .filter((featureId) => !isAclFeatureRemoved(featureId))\n\n if (!hasEnabledModulesRegistry()) {\n return filteredGrants.filter((featureId, index, features) => (\n featureId !== '*'\n && !featureId.endsWith('.*')\n && features.indexOf(featureId) === index\n ))\n }\n\n const result: string[] = []\n const seen = new Set<string>()\n const addFeature = (featureId: string) => {\n if (\n seen.has(featureId)\n || isAclFeatureRemoved(featureId)\n || !isFeatureEnabled(featureId)\n ) {\n return\n }\n seen.add(featureId)\n result.push(featureId)\n }\n\n for (const featureId of getConcreteFeatureIds()) {\n if (matchesAllFeatures(filteredGrants, [featureId])) addFeature(featureId)\n }\n\n for (const featureId of filteredGrants) {\n if (featureId === '*' || featureId.endsWith('.*')) continue\n addFeature(featureId)\n }\n\n return result\n}\n"],
5
+ "mappings": "AAAA,SAAS,kBAAkB,0BAA0B;AACrD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,kCAAkC;AAQpC,SAAS,0BAAoC;AAClD,SAAO,OAAO,QAAQ,2BAA2B,CAAC,EAC/C,OAAO,CAAC,CAAC,EAAE,QAAQ,MAAM,aAAa,IAAI,EAC1C,IAAI,CAAC,CAAC,SAAS,MAAM,SAAS;AACnC;AAEO,SAAS,oBAAoB,WAA4B;AAC9D,SAAO,2BAA2B,EAAE,SAAS,MAAM;AACrD;AAEA,SAAS,iBAAiB,WAA4B;AACpD,MAAI,CAAC,0BAA0B,EAAG,QAAO;AACzC,QAAM,mBAAmB,oBAAoB;AAC7C,SAAO,iBAAiB,SAAS,kBAAkB,SAAS,CAAC;AAC/D;AAEO,SAAS,kBACd,UACA,SACS;AACT,MAAI,SAAS,WAAW,EAAG,QAAO;AAClC,MAAI,QAAQ,iBAAiB,MAAO,QAAO;AAC3C,MAAI,SAAS,KAAK,CAAC,cACjB,oBAAoB,SAAS,KAAK,CAAC,iBAAiB,SAAS,CAC9D,GAAG;AACF,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,iBAAiB,KAAM,QAAO;AAC1C,SAAO;AAAA,IACL,6BAA6B,QAAQ,eAAe;AAAA,IACpD;AAAA,EACF;AACF;AAEO,SAAS,yBACd,iBACU;AACV,QAAM,iBAAiB,6BAA6B,eAAe,EAChE,OAAO,CAAC,cAAc,CAAC,oBAAoB,SAAS,CAAC;AAExD,MAAI,CAAC,0BAA0B,GAAG;AAChC,WAAO,eAAe,OAAO,CAAC,WAAW,OAAO,aAC9C,cAAc,OACX,CAAC,UAAU,SAAS,IAAI,KACxB,SAAS,QAAQ,SAAS,MAAM,KACpC;AAAA,EACH;AAEA,QAAM,SAAmB,CAAC;AAC1B,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,aAAa,CAAC,cAAsB;AACxC,QACE,KAAK,IAAI,SAAS,KACf,oBAAoB,SAAS,KAC7B,CAAC,iBAAiB,SAAS,GAC9B;AACA;AAAA,IACF;AACA,SAAK,IAAI,SAAS;AAClB,WAAO,KAAK,SAAS;AAAA,EACvB;AAEA,aAAW,aAAa,sBAAsB,GAAG;AAC/C,QAAI,mBAAmB,gBAAgB,CAAC,SAAS,CAAC,EAAG,YAAW,SAAS;AAAA,EAC3E;AAEA,aAAW,aAAa,gBAAgB;AACtC,QAAI,cAAc,OAAO,UAAU,SAAS,IAAI,EAAG;AACnD,eAAW,SAAS;AAAA,EACtB;AAEA,SAAO;AACT;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/shared",
3
- "version": "0.6.7-develop.6775.1.c2313bb8a3",
3
+ "version": "0.6.7-develop.6785.1.1dd7cfac55",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,6 +48,10 @@
48
48
  "types": "./src/lib/events/patterns.ts",
49
49
  "default": "./dist/lib/events/patterns.js"
50
50
  },
51
+ "./lib/data/consistency": {
52
+ "types": "./src/lib/data/consistency.ts",
53
+ "default": "./dist/lib/data/consistency.js"
54
+ },
51
55
  "./lib/logger": {
52
56
  "types": "./src/lib/logger/index.ts",
53
57
  "default": "./dist/lib/logger/index.js"
@@ -101,7 +105,7 @@
101
105
  "@mikro-orm/core": "^7.1.5",
102
106
  "@mikro-orm/decorators": "^7.1.5",
103
107
  "@mikro-orm/postgresql": "^7.1.5",
104
- "@open-mercato/cache": "0.6.7-develop.6775.1.c2313bb8a3",
108
+ "@open-mercato/cache": "0.6.7-develop.6785.1.1dd7cfac55",
105
109
  "@types/sanitize-html": "^2.16.1",
106
110
  "dotenv": "^17.4.2",
107
111
  "pino": "^10.3.1",
@@ -0,0 +1,59 @@
1
+ import type { EnvLookup, LlmProvider, LlmCreateModelOptions } from '../llm-provider'
2
+
3
+ function makeBaseProvider(overrides: Partial<LlmProvider> = {}): LlmProvider {
4
+ const id = overrides.id ?? 'contract-provider'
5
+ const envKeys = overrides.envKeys ?? [`${id.toUpperCase()}_API_KEY`]
6
+ const base: LlmProvider = {
7
+ id,
8
+ name: `Contract Provider ${id}`,
9
+ envKeys,
10
+ defaultModel: 'contract-model',
11
+ defaultModels: [{ id: 'contract-model', name: 'Contract Model', contextWindow: 8192 }],
12
+ isConfigured(env?: EnvLookup): boolean {
13
+ const lookup = env ?? process.env
14
+ return envKeys.some((key) => {
15
+ const value = lookup[key]
16
+ return typeof value === 'string' && value.trim().length > 0
17
+ })
18
+ },
19
+ resolveApiKey(): string | null {
20
+ return null
21
+ },
22
+ getConfiguredEnvKey(): string {
23
+ return envKeys[0]
24
+ },
25
+ createModel(options: LlmCreateModelOptions) {
26
+ return { __kind: 'contract-model', modelId: options.modelId }
27
+ },
28
+ }
29
+ return { ...base, ...overrides }
30
+ }
31
+
32
+ describe('LlmProvider contract — moderation/safety-identifier additive members', () => {
33
+ it('treats mapEndUserIdentifier and supportsInputModeration as optional (legacy adapters)', () => {
34
+ const provider = makeBaseProvider()
35
+ expect(provider.mapEndUserIdentifier).toBeUndefined()
36
+ expect(provider.supportsInputModeration).toBeUndefined()
37
+ })
38
+
39
+ it('accepts an optional endUserIdentifier on createModel options without changing behavior', () => {
40
+ const provider = makeBaseProvider()
41
+ const withIdentifier: LlmCreateModelOptions = {
42
+ modelId: 'contract-model',
43
+ apiKey: 'sk-test',
44
+ endUserIdentifier: 'hashed-identifier',
45
+ }
46
+ expect(provider.createModel(withIdentifier)).toEqual({ __kind: 'contract-model', modelId: 'contract-model' })
47
+ })
48
+
49
+ it('exposes a providerOptions fragment when mapEndUserIdentifier is implemented', () => {
50
+ const provider = makeBaseProvider({
51
+ mapEndUserIdentifier(identifier: string) {
52
+ return { contract: { user_id: identifier } }
53
+ },
54
+ supportsInputModeration: true,
55
+ })
56
+ expect(provider.supportsInputModeration).toBe(true)
57
+ expect(provider.mapEndUserIdentifier?.('abc123')).toEqual({ contract: { user_id: 'abc123' } })
58
+ })
59
+ })
@@ -0,0 +1,71 @@
1
+ import { computeEndUserIdentifier, deriveAiSafetyIdentifierSecret } from '../safety-identifier'
2
+
3
+ const BASE_SECRET = 'unit-test-base-secret'
4
+ const HEX_64 = /^[0-9a-f]{64}$/
5
+
6
+ describe('safety-identifier', () => {
7
+ describe('deriveAiSafetyIdentifierSecret', () => {
8
+ it('is deterministic and memoized for a given base secret', () => {
9
+ const first = deriveAiSafetyIdentifierSecret(BASE_SECRET)
10
+ const second = deriveAiSafetyIdentifierSecret(BASE_SECRET)
11
+ expect(first).toBe(second)
12
+ expect(first).toMatch(HEX_64)
13
+ })
14
+
15
+ it('produces a different key for a different base secret', () => {
16
+ expect(deriveAiSafetyIdentifierSecret(BASE_SECRET)).not.toBe(
17
+ deriveAiSafetyIdentifierSecret('other-base-secret'),
18
+ )
19
+ })
20
+
21
+ it('throws when no base secret is available', () => {
22
+ const previous = process.env.JWT_SECRET
23
+ delete process.env.JWT_SECRET
24
+ try {
25
+ expect(() => deriveAiSafetyIdentifierSecret()).toThrow(/JWT_SECRET/)
26
+ } finally {
27
+ if (previous !== undefined) process.env.JWT_SECRET = previous
28
+ }
29
+ })
30
+ })
31
+
32
+ describe('computeEndUserIdentifier', () => {
33
+ it('is stable for the same (tenant, user) pair', () => {
34
+ const a = computeEndUserIdentifier('tenant-1', 'user-1', { baseSecret: BASE_SECRET })
35
+ const b = computeEndUserIdentifier('tenant-1', 'user-1', { baseSecret: BASE_SECRET })
36
+ expect(a).toBe(b)
37
+ expect(a).toMatch(HEX_64)
38
+ })
39
+
40
+ it('separates the same user across tenants (tenant-salted)', () => {
41
+ const t1 = computeEndUserIdentifier('tenant-1', 'user-1', { baseSecret: BASE_SECRET })
42
+ const t2 = computeEndUserIdentifier('tenant-2', 'user-1', { baseSecret: BASE_SECRET })
43
+ expect(t1).not.toBe(t2)
44
+ })
45
+
46
+ it('separates different users within the same tenant', () => {
47
+ const u1 = computeEndUserIdentifier('tenant-1', 'user-1', { baseSecret: BASE_SECRET })
48
+ const u2 = computeEndUserIdentifier('tenant-1', 'user-2', { baseSecret: BASE_SECRET })
49
+ expect(u1).not.toBe(u2)
50
+ })
51
+
52
+ it('never leaks the raw tenant or user id', () => {
53
+ const id = computeEndUserIdentifier('tenant-1', 'user-1', { baseSecret: BASE_SECRET })
54
+ expect(id).not.toContain('tenant-1')
55
+ expect(id).not.toContain('user-1')
56
+ })
57
+
58
+ it('treats a null tenant as an empty salt without throwing', () => {
59
+ const withNull = computeEndUserIdentifier(null, 'user-1', { baseSecret: BASE_SECRET })
60
+ const withEmpty = computeEndUserIdentifier('', 'user-1', { baseSecret: BASE_SECRET })
61
+ expect(withNull).toMatch(HEX_64)
62
+ expect(withNull).toBe(withEmpty)
63
+ })
64
+
65
+ it('throws when userId is empty', () => {
66
+ expect(() => computeEndUserIdentifier('tenant-1', ' ', { baseSecret: BASE_SECRET })).toThrow(
67
+ /userId/,
68
+ )
69
+ })
70
+ })
71
+ })
@@ -62,6 +62,16 @@ export interface LlmCreateModelOptions {
62
62
  * proxy); Google honors it when the SDK supports it (@ai-sdk/google ≥3.0).
63
63
  */
64
64
  baseURL?: string
65
+ /**
66
+ * Optional opaque, non-reversible end-user identifier attached to the model
67
+ * call so provider-side abuse enforcement can target a single end user
68
+ * instead of the whole API-key organization. The runtime computes this as a
69
+ * tenant-salted HMAC (no PII leaves the platform); the adapter decides how to
70
+ * map it into per-call `providerOptions` via
71
+ * {@link LlmProvider.mapEndUserIdentifier}. Adapters without a mapping ignore
72
+ * it. Always optional — absent identifiers reproduce today's behavior.
73
+ */
74
+ endUserIdentifier?: string
65
75
  }
66
76
 
67
77
  /**
@@ -146,4 +156,27 @@ export interface LlmProvider {
146
156
  * behavior at `packages/ai-assistant/src/modules/ai_assistant/api/route/route.ts`.
147
157
  */
148
158
  createModel(options: LlmCreateModelOptions): unknown
159
+
160
+ /**
161
+ * Optional. Maps a runtime-computed end-user identifier (see
162
+ * {@link LlmCreateModelOptions.endUserIdentifier}) into the AI SDK
163
+ * `providerOptions` fragment this provider understands — e.g. OpenAI returns
164
+ * `{ openai: { safetyIdentifier } }`, Anthropic returns
165
+ * `{ anthropic: { metadata: { userId } } }`. Keys MUST be the AI SDK
166
+ * provider-option names (camelCase); the SDK translates them to the
167
+ * provider's request-body fields and strips unknown keys. The runtime merges
168
+ * the returned fragment into the per-call `providerOptions`. Adapters that
169
+ * omit this method send no identifier (today's behavior). Implementations
170
+ * MUST be pure and stateless.
171
+ */
172
+ mapEndUserIdentifier?(identifier: string): Record<string, unknown>
173
+
174
+ /**
175
+ * Optional. When `true`, the runtime may run input pre-moderation through
176
+ * this provider's moderation endpoint before the model call. Only providers
177
+ * that actually expose a moderation API (initially the OpenAI adapter) set
178
+ * this. Absent/`false` means the moderation gate is skipped for this provider
179
+ * and the surface relies on the provider's own server-side filtering.
180
+ */
181
+ readonly supportsInputModeration?: boolean
149
182
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * End-user safety identifiers for AI provider calls.
3
+ *
4
+ * Providers such as OpenAI let developers attach an opaque per-end-user
5
+ * identifier to each request so abuse enforcement can target one user instead
6
+ * of suspending the whole API-key organization. We never send a reversible id:
7
+ * the value is a tenant-salted HMAC computed from the platform's existing auth
8
+ * secret, so no PII or internal id leaves the platform and the same end user in
9
+ * two tenants produces two unrelated hashes.
10
+ *
11
+ * The per-process secret derivation mirrors `deriveJwtAudienceSecret`
12
+ * (`@open-mercato/shared/lib/auth/jwt`): one HMAC from the base `JWT_SECRET`
13
+ * under a versioned purpose label, memoized for the process lifetime. No new
14
+ * secret to provision.
15
+ *
16
+ * @see .ai/specs/2026-06-04-ai-input-moderation-and-safety-identifiers.md
17
+ */
18
+
19
+ import crypto from 'node:crypto'
20
+
21
+ const SAFETY_IDENTIFIER_SECRET_LABEL = 'open-mercato:ai-safety-identifier:v1'
22
+
23
+ const derivedSecretCache = new Map<string, string>()
24
+
25
+ function readBaseSecret(explicit?: string): string {
26
+ const secret = explicit ?? process.env.JWT_SECRET
27
+ if (!secret) {
28
+ throw new Error('[internal] JWT_SECRET is not set; cannot derive AI safety-identifier secret')
29
+ }
30
+ return secret
31
+ }
32
+
33
+ /**
34
+ * Derive the per-process safety-identifier HMAC key from the base auth secret.
35
+ *
36
+ * Deterministic HMAC-SHA256 of a versioned purpose label keyed by the base
37
+ * secret, memoized per base secret. Rotating the base secret rotates every
38
+ * derived identifier — documented as accepted (identifiers are advisory
39
+ * provider-side metadata, not an in-platform security control).
40
+ */
41
+ export function deriveAiSafetyIdentifierSecret(baseSecret?: string): string {
42
+ const base = readBaseSecret(baseSecret)
43
+ const cached = derivedSecretCache.get(base)
44
+ if (cached !== undefined) return cached
45
+ const derived = crypto
46
+ .createHmac('sha256', base)
47
+ .update(SAFETY_IDENTIFIER_SECRET_LABEL)
48
+ .digest('hex')
49
+ derivedSecretCache.set(base, derived)
50
+ return derived
51
+ }
52
+
53
+ /**
54
+ * Compute the opaque end-user safety identifier for a (tenant, user) pair.
55
+ *
56
+ * Returns a 64-char lowercase hex HMAC-SHA256 of `${tenantId}:${userId}` keyed
57
+ * by the derived secret. Throws (with an `[internal]` message) when the base
58
+ * secret is missing or `userId` is empty — callers in the runtime wrap this in
59
+ * a best-effort try/catch so identifier-derivation failures never break chat.
60
+ */
61
+ export function computeEndUserIdentifier(
62
+ tenantId: string | null | undefined,
63
+ userId: string,
64
+ options?: { baseSecret?: string },
65
+ ): string {
66
+ const normalizedUser = (userId ?? '').trim()
67
+ if (!normalizedUser) {
68
+ throw new Error('[internal] computeEndUserIdentifier requires a non-empty userId')
69
+ }
70
+ const key = deriveAiSafetyIdentifierSecret(options?.baseSecret)
71
+ const salt = (tenantId ?? '').trim()
72
+ return crypto.createHmac('sha256', key).update(`${salt}:${normalizedUser}`).digest('hex')
73
+ }
@@ -7,6 +7,10 @@ import {
7
7
  } from '../command-interceptor-runner'
8
8
  import type { CommandInterceptor, CommandInterceptorContext, CommandInterceptorUndoContext } from '../command-interceptor'
9
9
  import { createLogger } from '@open-mercato/shared/lib/logger'
10
+ import {
11
+ applyAclFeatureOverrides,
12
+ resetModuleContractOverridesForTests,
13
+ } from '@open-mercato/shared/modules/overrides'
10
14
 
11
15
  jest.mock('@open-mercato/shared/lib/logger', () => {
12
16
  const mocked = {
@@ -59,6 +63,10 @@ describe('matchesCommandPattern', () => {
59
63
  })
60
64
 
61
65
  describe('runCommandInterceptorsBefore', () => {
66
+ afterEach(() => {
67
+ resetModuleContractOverridesForTests()
68
+ })
69
+
62
70
  it('returns ok when no interceptors match', async () => {
63
71
  const interceptor = makeInterceptor({
64
72
  id: 'i1',
@@ -158,6 +166,26 @@ describe('runCommandInterceptorsBefore', () => {
158
166
  expect(withWildcard.ok).toBe(false)
159
167
  expect(withWildcard.error?.message).toBe('Blocked by wildcard')
160
168
  })
169
+
170
+ it('does not run an interceptor gated by a nulled ACL feature', async () => {
171
+ applyAclFeatureOverrides({ 'premium.audit': null })
172
+ const interceptor = makeInterceptor({
173
+ id: 'i1',
174
+ features: ['premium.audit'],
175
+ beforeExecute: jest.fn().mockResolvedValue({ ok: false }),
176
+ })
177
+
178
+ const result = await runCommandInterceptorsBefore(
179
+ [interceptor],
180
+ 'customers.create-person',
181
+ {},
182
+ baseContext,
183
+ ['*', 'premium.audit'],
184
+ )
185
+
186
+ expect(result.ok).toBe(true)
187
+ expect(interceptor.beforeExecute).not.toHaveBeenCalled()
188
+ })
161
189
  })
162
190
 
163
191
  describe('runCommandInterceptorsAfter', () => {
@@ -31,6 +31,7 @@ import {
31
31
  } from './command-interceptor-runner'
32
32
  import type { CommandInterceptorContext } from './command-interceptor'
33
33
  import { CommandInterceptorError } from './errors'
34
+ import { isReadProjectionAlwaysConsistent } from '@open-mercato/shared/lib/data/consistency'
34
35
  import { createLogger } from '../logger'
35
36
 
36
37
  const logger = createLogger('shared').child({ component: 'commands' })
@@ -697,7 +698,10 @@ export class CommandBus {
697
698
  try {
698
699
  const dataEngine = (container.resolve('dataEngine') as DataEngine)
699
700
  await dataEngine.flushOrmEntityChanges(suppress)
700
- } catch {
701
+ } catch (error) {
702
+ if (isReadProjectionAlwaysConsistent()) {
703
+ throw error
704
+ }
701
705
  // best-effort: failures should not block command execution
702
706
  }
703
707
  }
@@ -3,7 +3,7 @@ import type {
3
3
  CommandInterceptorContext,
4
4
  CommandInterceptorUndoContext,
5
5
  } from './command-interceptor'
6
- import { hasAllFeatures } from '../../security/features'
6
+ import { authorizeFeatures } from '../../security/featurePolicy'
7
7
  import { createLogger } from '../logger'
8
8
 
9
9
  const logger = createLogger('shared').child({ component: 'commands' })
@@ -33,7 +33,7 @@ function collectMatching(
33
33
  ): CommandInterceptor[] {
34
34
  return interceptors
35
35
  .filter((i) => matchesCommandPattern(i.targetCommand, commandId))
36
- .filter((i) => hasAllFeatures(userFeatures, i.features))
36
+ .filter((i) => authorizeFeatures(i.features ?? [], { grantedFeatures: userFeatures }))
37
37
  .sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
38
38
  }
39
39
 
@@ -545,6 +545,7 @@ describe('CRUD Factory', () => {
545
545
  const data = await res.json()
546
546
  expect(data.id).toBeDefined()
547
547
  // CF saved
548
+ expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
548
549
  expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ entityId: 'example.todo', values: { priority: 3 } }))
549
550
  // Event + indexer delegated to data engine
550
551
  expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
@@ -571,6 +572,7 @@ describe('CRUD Factory', () => {
571
572
  await em.persist(created).flush()
572
573
  const res = await route.PUT(new Request('http://x/api/example/todos', { method: 'PUT', body: JSON.stringify({ id: created.id, title: 'X2', cf_priority: 5 }), headers: { 'content-type': 'application/json' } }))
573
574
  expect(res.status).toBe(200)
575
+ expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
574
576
  expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ values: { priority: 5 } }))
575
577
  expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
576
578
  const updatedCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
@@ -592,6 +594,22 @@ describe('CRUD Factory', () => {
592
594
  expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
593
595
  })
594
596
 
597
+ it('POST surfaces CRUD side-effect failures after custom field writes', async () => {
598
+ mockDataEngine.emitOrmEntityEvent.mockImplementationOnce(async () => {
599
+ throw new Error('index write failed')
600
+ })
601
+
602
+ const res = await route.POST(new Request('http://x/api/example/todos', {
603
+ method: 'POST',
604
+ body: JSON.stringify({ title: 'Indexed', is_done: true, cf_priority: 3 }),
605
+ headers: { 'content-type': 'application/json' },
606
+ }))
607
+
608
+ expect(res.status).toBe(500)
609
+ expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
610
+ expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
611
+ })
612
+
595
613
  it('PUT rolls back the entity update when the custom field write fails', async () => {
596
614
  const created = em.create(Todo, { title: 'Before', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
597
615
  created.id = '123e4567-e89b-12d3-a456-426614174003'