@scenarist/core 0.3.1 → 0.3.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"in-memory-state-manager.d.ts","sourceRoot":"","sources":["../../src/adapters/in-memory-state-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAMrE;;;;;;GAMG;AACH,qBAAa,oBAAqB,YAAW,YAAY;IACvD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8C;IAEtE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO;IAUzC,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI;IAyBtD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAI/C,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAI3B,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAgB7D,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,cAAc;IA0CtB,OAAO,CAAC,cAAc;CA6BvB;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,QAAO,YAE7C,CAAC"}
1
+ {"version":3,"file":"in-memory-state-manager.d.ts","sourceRoot":"","sources":["../../src/adapters/in-memory-state-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAcrE;;;;;;GAMG;AACH,qBAAa,oBAAqB,YAAW,YAAY;IACvD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8C;IAEtE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO;IAUzC,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI;IAyBtD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAI/C,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAI3B,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAgB7D,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,cAAc;IA6CtB,OAAO,CAAC,cAAc;CA8BvB;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,QAAO,YAE7C,CAAC"}
@@ -1,5 +1,12 @@
1
1
  const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
2
2
  const isDangerousKey = (key) => DANGEROUS_KEYS.has(key);
3
+ /**
4
+ * Type guard to check if a value is a plain object (Record).
5
+ * Used to properly narrow types after typeof checks.
6
+ */
7
+ const isRecord = (value) => {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ };
3
10
  /**
4
11
  * In-memory implementation of StateManager port.
5
12
  * Fast, single-process state storage for stateful mocks.
@@ -80,21 +87,24 @@ export class InMemoryStateManager {
80
87
  });
81
88
  return;
82
89
  }
90
+ // Get or create nested object for this path segment
83
91
  // eslint-disable-next-line security/detect-object-injection -- Guarded by isDangerousKey and Object.hasOwn
84
92
  const existingValue = Object.hasOwn(obj, key) ? obj[key] : undefined;
85
- if (typeof existingValue !== "object" ||
86
- existingValue === null ||
87
- Array.isArray(existingValue)) {
93
+ // Determine target: use existing record OR create new empty object
94
+ const target = isRecord(existingValue)
95
+ ? existingValue
96
+ : {};
97
+ // If we created a new object, assign it to the parent
98
+ if (!isRecord(existingValue)) {
88
99
  Object.defineProperty(obj, key, {
89
- value: {},
100
+ value: target,
90
101
  writable: true,
91
102
  enumerable: true,
92
103
  configurable: true,
93
104
  });
94
105
  }
95
- // eslint-disable-next-line security/detect-object-injection -- Guarded by isDangerousKey, Object.hasOwn, and Object.defineProperty
96
- const nested = obj[key];
97
- this.setNestedValue(nested, path.slice(1), value);
106
+ // Recurse to next path segment
107
+ this.setNestedValue(target, path.slice(1), value);
98
108
  }
99
109
  getNestedValue(obj, path) {
100
110
  const key = path[0];
@@ -111,7 +121,8 @@ export class InMemoryStateManager {
111
121
  if (path.length === 1) {
112
122
  return value;
113
123
  }
114
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
124
+ // Use type guard for proper type narrowing instead of assertion
125
+ if (!isRecord(value)) {
115
126
  return undefined;
116
127
  }
117
128
  return this.getNestedValue(value, path.slice(1));
@@ -1 +1 @@
1
- {"version":3,"file":"deep-equals.d.ts","sourceRoot":"","sources":["../../src/domain/deep-equals.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,eAAO,MAAM,UAAU,GAAI,GAAG,OAAO,EAAE,GAAG,OAAO,KAAG,OAoEnD,CAAC"}
1
+ {"version":3,"file":"deep-equals.d.ts","sourceRoot":"","sources":["../../src/domain/deep-equals.ts"],"names":[],"mappings":"AAQA;;;;;;;;;GASG;AACH,eAAO,MAAM,UAAU,GAAI,GAAG,OAAO,EAAE,GAAG,OAAO,KAAG,OA+DnD,CAAC"}
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Type guard to check if a value is a plain object (Record).
3
+ * Used to properly narrow types after typeof checks.
4
+ */
5
+ const isRecord = (value) => {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ };
1
8
  /**
2
9
  * Deep equality comparison for values.
3
10
  *
@@ -42,8 +49,8 @@ export const deepEquals = (a, b) => {
42
49
  if (Array.isArray(a) !== Array.isArray(b)) {
43
50
  return false;
44
51
  }
45
- // Handle objects
46
- if (typeof a === "object" && typeof b === "object") {
52
+ // Handle objects (use type guard instead of type assertion)
53
+ if (isRecord(a) && isRecord(b)) {
47
54
  const aKeys = Object.keys(a);
48
55
  const bKeys = Object.keys(b);
49
56
  if (aKeys.length !== bKeys.length) {
@@ -1 +1 @@
1
- {"version":3,"file":"path-extraction.d.ts","sourceRoot":"","sources":["../../src/domain/path-extraction.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAM/D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC1B,SAAS,kBAAkB,EAC3B,MAAM,MAAM,KACX,OAyBF,CAAC"}
1
+ {"version":3,"file":"path-extraction.d.ts","sourceRoot":"","sources":["../../src/domain/path-extraction.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAc/D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC1B,SAAS,kBAAkB,EAC3B,MAAM,MAAM,KACX,OAyBF,CAAC"}
@@ -1,5 +1,12 @@
1
1
  const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
2
2
  const isDangerousKey = (key) => DANGEROUS_KEYS.has(key);
3
+ /**
4
+ * Type guard to check if a value is a plain object (Record).
5
+ * Used to properly narrow types after typeof checks.
6
+ */
7
+ const isRecord = (value) => {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ };
3
10
  /**
4
11
  * Extracts a value from HttpRequestContext based on a path expression.
5
12
  *
@@ -55,8 +62,8 @@ const traversePath = (obj, path) => {
55
62
  if (path.length === 0) {
56
63
  return obj;
57
64
  }
58
- // Guard: Can only traverse objects
59
- if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
65
+ // Guard: Can only traverse objects - use type guard for proper narrowing
66
+ if (!isRecord(obj)) {
60
67
  return undefined;
61
68
  }
62
69
  const key = path[0];
@@ -68,9 +75,8 @@ const traversePath = (obj, path) => {
68
75
  if (!Object.hasOwn(obj, key)) {
69
76
  return undefined;
70
77
  }
71
- const record = obj;
72
78
  // eslint-disable-next-line security/detect-object-injection -- Key validated by Object.hasOwn and isDangerousKey guard
73
- const value = record[key];
79
+ const value = obj[key];
74
80
  // Recursively traverse remaining path
75
81
  return traversePath(value, path.slice(1));
76
82
  };
@@ -1 +1 @@
1
- {"version":3,"file":"response-selector.d.ts","sourceRoot":"","sources":["../../src/domain/response-selector.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,gBAAgB,EAChB,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAiB3B;;GAEG;AACH,KAAK,6BAA6B,GAAG;IACnC,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,GACjC,UAAS,6BAAkC,KAC1C,gBAuQF,CAAC"}
1
+ {"version":3,"file":"response-selector.d.ts","sourceRoot":"","sources":["../../src/domain/response-selector.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,gBAAgB,EAChB,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAyB3B;;GAEG;AACH,KAAK,6BAA6B,GAAG;IACnC,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,GACjC,UAAS,6BAAkC,KAC1C,gBAwQF,CAAC"}
@@ -6,6 +6,13 @@ import { createStateResponseResolver } from "./state-response-resolver.js";
6
6
  import { deepEquals } from "./deep-equals.js";
7
7
  import { noOpLogger } from "../adapters/index.js";
8
8
  import { LogCategories, LogEvents } from "./log-events.js";
9
+ /**
10
+ * Type guard to check if a value is a plain object (Record).
11
+ * Used to properly narrow types after typeof checks.
12
+ */
13
+ const isRecord = (value) => {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ };
9
16
  const SPECIFICITY_RANGES = {
10
17
  MATCH_CRITERIA_BASE: 100,
11
18
  SEQUENCE_FALLBACK: 1,
@@ -139,6 +146,7 @@ export const createResponseSelector = (options = {}) => {
139
146
  state: currentState,
140
147
  params: mockWithParams.params || {},
141
148
  };
149
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- applyTemplates preserves structure; input ScenaristResponse → output ScenaristResponse
142
150
  finalResponse = applyTemplates(response, templateData);
143
151
  }
144
152
  // Apply afterResponse.setState to mutate state for subsequent requests
@@ -393,15 +401,14 @@ const matchesState = (stateCriteria, testId, stateManager) => {
393
401
  * Non-string values are converted to strings before matching.
394
402
  */
395
403
  const matchesBody = (requestBody, criteriaBody) => {
396
- // If request has no body, can't match
397
- if (!requestBody || typeof requestBody !== "object") {
404
+ // If request has no body, can't match - use type guard for proper narrowing
405
+ if (!isRecord(requestBody)) {
398
406
  return false;
399
407
  }
400
- const body = requestBody;
401
408
  // Check all required fields exist in request body with matching values
402
409
  for (const [key, criteriaValue] of Object.entries(criteriaBody)) {
403
410
  // eslint-disable-next-line security/detect-object-injection -- Key from Object.entries iteration
404
- const requestValue = body[key];
411
+ const requestValue = requestBody[key];
405
412
  // Convert to string for matching (type coercion like headers/query)
406
413
  const stringValue = requestValue == null ? "" : String(requestValue);
407
414
  if (!matchesValue(stringValue, criteriaValue)) {
@@ -461,21 +468,30 @@ const matchesValue = (requestValue, criteriaValue) => {
461
468
  if (criteriaValue == null) {
462
469
  return requestValue === "";
463
470
  }
464
- const strategyValue = criteriaValue;
465
- if (strategyValue.equals !== undefined) {
466
- return requestValue === String(strategyValue.equals);
471
+ // After ruling out string, RegExp, number, boolean, null - remaining must be object strategy
472
+ if (!isRecord(criteriaValue)) {
473
+ return false;
467
474
  }
468
- if (strategyValue.contains !== undefined) {
469
- return requestValue.includes(String(strategyValue.contains));
475
+ if (criteriaValue.equals !== undefined) {
476
+ return requestValue === String(criteriaValue.equals);
470
477
  }
471
- if (strategyValue.startsWith !== undefined) {
472
- return requestValue.startsWith(String(strategyValue.startsWith));
478
+ if (criteriaValue.contains !== undefined) {
479
+ return requestValue.includes(String(criteriaValue.contains));
473
480
  }
474
- if (strategyValue.endsWith !== undefined) {
475
- return requestValue.endsWith(String(strategyValue.endsWith));
481
+ if (criteriaValue.startsWith !== undefined) {
482
+ return requestValue.startsWith(String(criteriaValue.startsWith));
476
483
  }
477
- if (strategyValue.regex !== undefined) {
478
- return matchesRegex(requestValue, strategyValue.regex);
484
+ if (criteriaValue.endsWith !== undefined) {
485
+ return requestValue.endsWith(String(criteriaValue.endsWith));
486
+ }
487
+ if (criteriaValue.regex !== undefined && isRecord(criteriaValue.regex)) {
488
+ const regex = criteriaValue.regex;
489
+ if (typeof regex.source === "string") {
490
+ return matchesRegex(requestValue, {
491
+ source: regex.source,
492
+ flags: typeof regex.flags === "string" ? regex.flags : undefined,
493
+ });
494
+ }
479
495
  }
480
496
  return false;
481
497
  };
@@ -1 +1 @@
1
- {"version":3,"file":"scenario-manager.d.ts","sourceRoot":"","sources":["../../src/domain/scenario-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAwC3B;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,GAAI,6DAMnC;IACD,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,KAAG,eAwHH,CAAC"}
1
+ {"version":3,"file":"scenario-manager.d.ts","sourceRoot":"","sources":["../../src/domain/scenario-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAwC3B;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,GAAI,6DAMnC;IACD,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,KAAG,eA+HH,CAAC"}
@@ -40,7 +40,13 @@ export const createScenarioManager = ({ registry, store, stateManager, sequenceT
40
40
  const validationResult = ScenaristScenarioSchema.safeParse(definition);
41
41
  if (!validationResult.success) {
42
42
  const errorMessages = validationResult.error.issues.map((err) => `${err.path.join(".")}: ${err.message}`);
43
- const scenarioId = definition?.id || "<unknown>";
43
+ // Extract id safely without type assertion - definition failed validation so type is untrusted
44
+ const scenarioId = typeof definition === "object" &&
45
+ definition !== null &&
46
+ "id" in definition &&
47
+ typeof definition.id === "string"
48
+ ? definition.id
49
+ : "<unknown>";
44
50
  throw createScenarioValidationError(scenarioId, errorMessages);
45
51
  }
46
52
  const existing = registry.get(definition.id);
@@ -2,6 +2,10 @@
2
2
  * Applies templates to a value.
3
3
  * Replaces {{state.key}} and {{params.key}} patterns with actual values.
4
4
  *
5
+ * Note: This function preserves the structure of the input value.
6
+ * Callers passing typed objects (like ScenaristResponse) can safely
7
+ * cast the return value back to the input type.
8
+ *
5
9
  * @param value - Value to apply templates to (string, object, array, or primitive)
6
10
  * @param templateData - Object containing state and params for template replacement.
7
11
  * Can be flat object (backward compatible) or { state: {...}, params: {...} }
@@ -1 +1 @@
1
- {"version":3,"file":"template-replacement.d.ts","sourceRoot":"","sources":["../../src/domain/template-replacement.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,GACzB,OAAO,OAAO,EACd,cAAc,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACpC,OA+DF,CAAC"}
1
+ {"version":3,"file":"template-replacement.d.ts","sourceRoot":"","sources":["../../src/domain/template-replacement.ts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,GACzB,OAAO,OAAO,EACd,cAAc,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACpC,OA+DF,CAAC"}
@@ -1,7 +1,25 @@
1
+ /**
2
+ * Type guard to check if a value is a plain object (Record).
3
+ * Used to properly narrow types after typeof checks.
4
+ */
5
+ const isRecord = (value) => {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ };
8
+ /**
9
+ * Security: Check if a property key could cause prototype pollution.
10
+ * @see https://github.com/citypaul/scenarist/security/code-scanning
11
+ */
12
+ const isDangerousKey = (key) => {
13
+ return key === "__proto__" || key === "constructor" || key === "prototype";
14
+ };
1
15
  /**
2
16
  * Applies templates to a value.
3
17
  * Replaces {{state.key}} and {{params.key}} patterns with actual values.
4
18
  *
19
+ * Note: This function preserves the structure of the input value.
20
+ * Callers passing typed objects (like ScenaristResponse) can safely
21
+ * cast the return value back to the input type.
22
+ *
5
23
  * @param value - Value to apply templates to (string, object, array, or primitive)
6
24
  * @param templateData - Object containing state and params for template replacement.
7
25
  * Can be flat object (backward compatible) or { state: {...}, params: {...} }
@@ -86,10 +104,18 @@ const resolveTemplatePath = (templateData, prefix, path) => {
86
104
  if (Array.isArray(current) && segment === "length") {
87
105
  return current.length;
88
106
  }
89
- // Traverse object
90
- const record = current;
91
- // eslint-disable-next-line security/detect-object-injection -- Segment from split() iteration
92
- current = record[segment];
107
+ // Traverse object - use type guard for proper narrowing
108
+ if (!isRecord(current)) {
109
+ return undefined;
110
+ }
111
+ // Security: Prevent prototype pollution attacks
112
+ // @see https://github.com/citypaul/scenarist/security/code-scanning/165
113
+ if (isDangerousKey(segment) || !Object.hasOwn(current, segment)) {
114
+ return undefined;
115
+ }
116
+ // nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop
117
+ // eslint-disable-next-line security/detect-object-injection -- Segment validated by isDangerousKey and Object.hasOwn checks above
118
+ current = current[segment];
93
119
  // Guard: Return undefined if property doesn't exist
94
120
  if (current === undefined) {
95
121
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenarist/core",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Internal: Hexagonal architecture core for scenario-based testing with MSW",
5
5
  "author": "Paul Hammond (citypaul) <paul@packsoftware.co.uk>",
6
6
  "license": "MIT",