@safeaccess/inline 0.1.2 → 0.1.3
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.
- package/CHANGELOG.md +15 -2
- package/README.md +25 -9
- package/dist/accessors/abstract-accessor.d.ts +2 -0
- package/dist/inline.d.ts +4 -0
- package/dist/inline.js +4 -0
- package/dist/security/security-guard.d.ts +1 -0
- package/dist/security/security-guard.js +2 -0
- package/package.json +1 -1
- package/src/accessors/abstract-accessor.ts +2 -0
- package/src/inline.ts +4 -0
- package/src/security/security-guard.ts +3 -0
- package/tests/parser/xml-parser-scanner.test.ts +47 -0
- package/tests/parser/xml-parser.test.ts +11 -0
- package/tests/security/security-guard.test.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,12 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `@safeaccess/inline` JavaScript/TypeScript package are documented in this file.
|
|
4
4
|
|
|
5
|
-
## [0.1.
|
|
5
|
+
## [0.1.3](https://github.com/felipesauer/safeaccess-inline/compare/js-v0.1.2...js-v0.1.3) (2026-04-09)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
6
9
|
|
|
10
|
+
* **js:** expose readonly extraForbiddenKeys on SecurityGuard for PHP parity ([2b428f6](https://github.com/felipesauer/safeaccess-inline/commit/2b428f6a1fef3607cb968ff18b52d8281158cc92))
|
|
11
|
+
* **php:** correct array<string,mixed> type annotations and NdjsonAccessor integer key coercion ([7849f89](https://github.com/felipesauer/safeaccess-inline/commit/7849f89365bd5970738105ed3be9d2b58a15cd93))
|
|
12
|
+
|
|
13
|
+
## [0.1.2](https://github.com/felipesauer/safeaccess-inline/compare/js-v0.1.1...js-v0.1.2) (2026-04-08)
|
|
7
14
|
|
|
8
15
|
### Bug Fixes
|
|
9
16
|
|
|
10
|
-
|
|
17
|
+
- **js:** fix logo image URL in README ([16f4fc5](https://github.com/felipesauer/safeaccess-inline/commit/16f4fc5d69fa7ce86e3017bbbfc9f393925a5c37))
|
|
18
|
+
|
|
19
|
+
### Internal Changes
|
|
20
|
+
|
|
21
|
+
- **js:** expose `readonly extraForbiddenKeys` on `SecurityGuard` for parity with PHP (`public readonly array $extraForbiddenKeys`)
|
|
22
|
+
- **js:** extract `ValidatableParserInterface` from `DotNotationParser` — `AbstractAccessor` now types its parser dependency against this contract instead of the concrete class
|
|
23
|
+
- **js:** `SecurityGuard.sanitize()` handles nested arrays via a dedicated `sanitizeArray()` private method, matching the PHP `sanitizeRecursive` pattern
|
|
11
24
|
|
|
12
25
|
## [0.1.1](https://github.com/felipesauer/safeaccess-inline/compare/js-v0.1.0...js-v0.1.1) (2026-04-07)
|
|
13
26
|
|
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://
|
|
2
|
+
<img src="https://raw.githubusercontent.com/felipesauer/safeaccess-inline/main/.github/assets/logo.svg" width="80" alt="safeaccess-inline logo">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<h1 align="center">Safe Access Inline - TypeScript</h1>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
-
Safe nested data access with dot notation for JavaScript and TypeScript. Navigate deeply nested objects, JSON, YAML, XML, INI, ENV, and NDJSON structures - with built-in security validation, immutable writes, and a fluent builder API.
|
|
14
|
+
Safe nested data access with dot notation for JavaScript and TypeScript. Navigate deeply nested arrays, objects, JSON, YAML, XML, INI, ENV, and NDJSON structures - with built-in security validation, immutable writes, and a fluent builder API.
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
@@ -151,7 +151,7 @@ accessor.get('database.host'); // 'localhost'
|
|
|
151
151
|
<summary><strong>ENV (dotenv)</strong></summary>
|
|
152
152
|
|
|
153
153
|
```typescript
|
|
154
|
-
const accessor = Inline.fromEnv('APP_NAME=MyApp\nDB_HOST=localhost');
|
|
154
|
+
const accessor = Inline.fromEnv('APP_NAME=MyApp\nAPP_DEBUG=true\nDB_HOST=localhost');
|
|
155
155
|
accessor.get('DB_HOST'); // 'localhost'
|
|
156
156
|
```
|
|
157
157
|
|
|
@@ -181,6 +181,20 @@ objAccessor.get('name'); // 'Alice'
|
|
|
181
181
|
|
|
182
182
|
</details>
|
|
183
183
|
|
|
184
|
+
<details>
|
|
185
|
+
<summary><strong>Any (custom format via integration)</strong></summary>
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
import { Inline } from '@safeaccess/inline';
|
|
189
|
+
import type { ParseIntegrationInterface } from '@safeaccess/inline';
|
|
190
|
+
|
|
191
|
+
// Requires implementing ParseIntegrationInterface
|
|
192
|
+
const accessor = Inline.withParserIntegration(new MyCsvIntegration()).fromAny(csvString);
|
|
193
|
+
accessor.get('0.column_name');
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
</details>
|
|
197
|
+
|
|
184
198
|
<details>
|
|
185
199
|
<summary><strong>Dynamic (by TypeFormat enum)</strong></summary>
|
|
186
200
|
|
|
@@ -215,10 +229,8 @@ accessor.getMany({
|
|
|
215
229
|
accessor.getRaw(); // original JSON string
|
|
216
230
|
|
|
217
231
|
// Write (immutable - every write returns a new instance)
|
|
218
|
-
const updated = accessor.set('a.d', 3);
|
|
219
|
-
|
|
220
|
-
const merged = cleaned.merge('a', { e: 4 });
|
|
221
|
-
const full = merged.mergeAll({ f: 5 });
|
|
232
|
+
const updated = accessor.set('a.d', 3).remove('a.c').merge('a', { e: 4 }).mergeAll({ f: 5 });
|
|
233
|
+
updated.all(); // { a: { b: 1, d: 3, e: 4 }, f: 5 }
|
|
222
234
|
|
|
223
235
|
// Readonly mode - block all writes
|
|
224
236
|
const readonly = accessor.readonly();
|
|
@@ -245,7 +257,7 @@ const accessor = Inline.withSecurityGuard(new SecurityGuard(512, ['secret']))
|
|
|
245
257
|
| ------------------------------------ | ------------------------------------------------ |
|
|
246
258
|
| `withSecurityGuard(guard)` | Custom forbidden-key rules and depth limits |
|
|
247
259
|
| `withSecurityParser(parser)` | Custom payload size and structural limits |
|
|
248
|
-
| `withPathCache(cache)` |
|
|
260
|
+
| `withPathCache(cache)` | Path segment cache for repeated lookups |
|
|
249
261
|
| `withParserIntegration(integration)` | Custom format parser for `fromAny()` |
|
|
250
262
|
| `withStrictMode(false)` | Disable security validation (trusted input only) |
|
|
251
263
|
|
|
@@ -391,7 +403,7 @@ const accessor = Inline.withParserIntegration(csvIntegration).fromAny(csvString)
|
|
|
391
403
|
|
|
392
404
|
## API Reference
|
|
393
405
|
|
|
394
|
-
###
|
|
406
|
+
### `Inline` Facade
|
|
395
407
|
|
|
396
408
|
#### Static Factory Methods
|
|
397
409
|
|
|
@@ -442,6 +454,10 @@ const accessor = Inline.withParserIntegration(csvIntegration).fromAny(csvString)
|
|
|
442
454
|
| `readonly(flag?)` | Block all writes |
|
|
443
455
|
| `strict(flag?)` | Toggle security validation |
|
|
444
456
|
|
|
457
|
+
#### TypeFormat Enum
|
|
458
|
+
|
|
459
|
+
`Array` · `Object` · `Json` · `Xml` · `Yaml` · `Ini` · `Env` · `Ndjson` · `Any`
|
|
460
|
+
|
|
445
461
|
## Exports
|
|
446
462
|
|
|
447
463
|
The package uses **named exports only** (no default exports). All public types are available from the main entry point:
|
|
@@ -45,6 +45,8 @@ export declare abstract class AbstractAccessor implements AccessorsInterface {
|
|
|
45
45
|
*
|
|
46
46
|
* @param data - Raw input in the format expected by the accessor.
|
|
47
47
|
* @returns Populated accessor instance.
|
|
48
|
+
* @throws {InvalidFormatException} When the raw input cannot be parsed.
|
|
49
|
+
* @throws {SecurityException} When payload exceeds size limit, data contains forbidden keys, or violates structural limits.
|
|
48
50
|
*/
|
|
49
51
|
abstract from(data: unknown): this;
|
|
50
52
|
/**
|
package/dist/inline.d.ts
CHANGED
|
@@ -209,6 +209,8 @@ export declare class Inline extends InlineBuilderAccessor {
|
|
|
209
209
|
* @param AccessorConstructor - The accessor class to instantiate.
|
|
210
210
|
* @param data - Raw data to hydrate the accessor with.
|
|
211
211
|
* @returns Populated accessor instance.
|
|
212
|
+
* @throws {InvalidFormatException} When the data does not match the accessor's expected format.
|
|
213
|
+
* @throws {SecurityException} When security constraints are violated.
|
|
212
214
|
*
|
|
213
215
|
* @example
|
|
214
216
|
* Inline.make(JsonAccessor, '{"key":"value"}').get('key'); // 'value'
|
|
@@ -360,6 +362,8 @@ export declare class Inline extends InlineBuilderAccessor {
|
|
|
360
362
|
* @param AccessorConstructor - The accessor class to instantiate.
|
|
361
363
|
* @param data - Raw data to hydrate the accessor with.
|
|
362
364
|
* @returns Populated accessor instance.
|
|
365
|
+
* @throws {InvalidFormatException} When the data does not match the accessor's expected format.
|
|
366
|
+
* @throws {SecurityException} When security constraints are violated.
|
|
363
367
|
*
|
|
364
368
|
* @example
|
|
365
369
|
* Inline.make(JsonAccessor, '{"key":"value"}').get('key'); // 'value'
|
package/dist/inline.js
CHANGED
|
@@ -227,6 +227,8 @@ export class Inline extends InlineBuilderAccessor {
|
|
|
227
227
|
* @param AccessorConstructor - The accessor class to instantiate.
|
|
228
228
|
* @param data - Raw data to hydrate the accessor with.
|
|
229
229
|
* @returns Populated accessor instance.
|
|
230
|
+
* @throws {InvalidFormatException} When the data does not match the accessor's expected format.
|
|
231
|
+
* @throws {SecurityException} When security constraints are violated.
|
|
230
232
|
*
|
|
231
233
|
* @example
|
|
232
234
|
* Inline.make(JsonAccessor, '{"key":"value"}').get('key'); // 'value'
|
|
@@ -430,6 +432,8 @@ export class Inline extends InlineBuilderAccessor {
|
|
|
430
432
|
* @param AccessorConstructor - The accessor class to instantiate.
|
|
431
433
|
* @param data - Raw data to hydrate the accessor with.
|
|
432
434
|
* @returns Populated accessor instance.
|
|
435
|
+
* @throws {InvalidFormatException} When the data does not match the accessor's expected format.
|
|
436
|
+
* @throws {SecurityException} When security constraints are violated.
|
|
433
437
|
*
|
|
434
438
|
* @example
|
|
435
439
|
* Inline.make(JsonAccessor, '{"key":"value"}').get('key'); // 'value'
|
|
@@ -24,6 +24,7 @@ import type { SecurityGuardInterface } from '../contracts/security-guard-interfa
|
|
|
24
24
|
*/
|
|
25
25
|
export declare class SecurityGuard implements SecurityGuardInterface {
|
|
26
26
|
readonly maxDepth: number;
|
|
27
|
+
readonly extraForbiddenKeys: ReadonlyArray<string>;
|
|
27
28
|
private readonly forbiddenKeysMap;
|
|
28
29
|
/**
|
|
29
30
|
* Build the guard with default forbidden keys plus any extras.
|
|
@@ -25,6 +25,7 @@ import { DEFAULT_FORBIDDEN_KEYS, STREAM_WRAPPER_PREFIXES } from './forbidden-key
|
|
|
25
25
|
*/
|
|
26
26
|
export class SecurityGuard {
|
|
27
27
|
maxDepth;
|
|
28
|
+
extraForbiddenKeys;
|
|
28
29
|
forbiddenKeysMap;
|
|
29
30
|
/**
|
|
30
31
|
* Build the guard with default forbidden keys plus any extras.
|
|
@@ -35,6 +36,7 @@ export class SecurityGuard {
|
|
|
35
36
|
constructor(maxDepth = 512,
|
|
36
37
|
/* Stryker disable next-line ArrayDeclaration -- equivalent: default [] produces identical behavior; no extra keys added to Set */ extraForbiddenKeys = []) {
|
|
37
38
|
this.maxDepth = Number.isFinite(maxDepth) ? maxDepth : 512;
|
|
39
|
+
this.extraForbiddenKeys = [...extraForbiddenKeys];
|
|
38
40
|
/* Stryker disable next-line ConditionalExpression -- equivalent: if (false) still produces the same forbiddenKeysMap for empty arrays since Set(DEFAULT)=DEFAULT */
|
|
39
41
|
if (extraForbiddenKeys.length === 0) {
|
|
40
42
|
this.forbiddenKeysMap = DEFAULT_FORBIDDEN_KEYS;
|
package/package.json
CHANGED
|
@@ -75,6 +75,8 @@ export abstract class AbstractAccessor implements AccessorsInterface {
|
|
|
75
75
|
*
|
|
76
76
|
* @param data - Raw input in the format expected by the accessor.
|
|
77
77
|
* @returns Populated accessor instance.
|
|
78
|
+
* @throws {InvalidFormatException} When the raw input cannot be parsed.
|
|
79
|
+
* @throws {SecurityException} When payload exceeds size limit, data contains forbidden keys, or violates structural limits.
|
|
78
80
|
*/
|
|
79
81
|
abstract from(data: unknown): this;
|
|
80
82
|
|
package/src/inline.ts
CHANGED
|
@@ -259,6 +259,8 @@ export class Inline extends InlineBuilderAccessor {
|
|
|
259
259
|
* @param AccessorConstructor - The accessor class to instantiate.
|
|
260
260
|
* @param data - Raw data to hydrate the accessor with.
|
|
261
261
|
* @returns Populated accessor instance.
|
|
262
|
+
* @throws {InvalidFormatException} When the data does not match the accessor's expected format.
|
|
263
|
+
* @throws {SecurityException} When security constraints are violated.
|
|
262
264
|
*
|
|
263
265
|
* @example
|
|
264
266
|
* Inline.make(JsonAccessor, '{"key":"value"}').get('key'); // 'value'
|
|
@@ -479,6 +481,8 @@ export class Inline extends InlineBuilderAccessor {
|
|
|
479
481
|
* @param AccessorConstructor - The accessor class to instantiate.
|
|
480
482
|
* @param data - Raw data to hydrate the accessor with.
|
|
481
483
|
* @returns Populated accessor instance.
|
|
484
|
+
* @throws {InvalidFormatException} When the data does not match the accessor's expected format.
|
|
485
|
+
* @throws {SecurityException} When security constraints are violated.
|
|
482
486
|
*
|
|
483
487
|
* @example
|
|
484
488
|
* Inline.make(JsonAccessor, '{"key":"value"}').get('key'); // 'value'
|
|
@@ -28,6 +28,8 @@ import { DEFAULT_FORBIDDEN_KEYS, STREAM_WRAPPER_PREFIXES } from './forbidden-key
|
|
|
28
28
|
export class SecurityGuard implements SecurityGuardInterface {
|
|
29
29
|
readonly maxDepth: number;
|
|
30
30
|
|
|
31
|
+
readonly extraForbiddenKeys: ReadonlyArray<string>;
|
|
32
|
+
|
|
31
33
|
private readonly forbiddenKeysMap: ReadonlySet<string>;
|
|
32
34
|
|
|
33
35
|
/**
|
|
@@ -41,6 +43,7 @@ export class SecurityGuard implements SecurityGuardInterface {
|
|
|
41
43
|
/* Stryker disable next-line ArrayDeclaration -- equivalent: default [] produces identical behavior; no extra keys added to Set */ extraForbiddenKeys: string[] = [],
|
|
42
44
|
) {
|
|
43
45
|
this.maxDepth = Number.isFinite(maxDepth) ? maxDepth : 512;
|
|
46
|
+
this.extraForbiddenKeys = [...extraForbiddenKeys];
|
|
44
47
|
|
|
45
48
|
/* Stryker disable next-line ConditionalExpression -- equivalent: if (false) still produces the same forbiddenKeysMap for empty arrays since Set(DEFAULT)=DEFAULT */
|
|
46
49
|
if (extraForbiddenKeys.length === 0) {
|
|
@@ -253,6 +253,24 @@ describe(`${XmlParser.name} > linear scanner - nesting counter`, () => {
|
|
|
253
253
|
const a = result['a'] as Record<string, unknown>;
|
|
254
254
|
expect(a['a']).toBe('');
|
|
255
255
|
});
|
|
256
|
+
|
|
257
|
+
it('increments nestDepth for same-name opening tag with tab after name', () => {
|
|
258
|
+
const result = makeParser().parse('<root><a><a\t>inner</a></a></root>');
|
|
259
|
+
const a = result['a'] as Record<string, unknown>;
|
|
260
|
+
expect(a).toEqual({ a: 'inner' });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('increments nestDepth for same-name opening tag with newline after name', () => {
|
|
264
|
+
const result = makeParser().parse('<root><a><a\n>inner</a></a></root>');
|
|
265
|
+
const a = result['a'] as Record<string, unknown>;
|
|
266
|
+
expect(a).toEqual({ a: 'inner' });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('increments nestDepth for same-name opening tag with carriage return after name', () => {
|
|
270
|
+
const result = makeParser().parse('<root><a><a\r>inner</a></a></root>');
|
|
271
|
+
const a = result['a'] as Record<string, unknown>;
|
|
272
|
+
expect(a).toEqual({ a: 'inner' });
|
|
273
|
+
});
|
|
256
274
|
});
|
|
257
275
|
|
|
258
276
|
describe(`${XmlParser.name} > linear scanner - self-closing detection`, () => {
|
|
@@ -265,6 +283,11 @@ describe(`${XmlParser.name} > linear scanner - self-closing detection`, () => {
|
|
|
265
283
|
const result = makeParser().parse('<root><flag enabled="true" /></root>');
|
|
266
284
|
expect(result['flag']).toBe('');
|
|
267
285
|
});
|
|
286
|
+
|
|
287
|
+
it('treats <tag / > (space after slash before >) as self-closing', () => {
|
|
288
|
+
const result = makeParser().parse('<root><empty / ></root>');
|
|
289
|
+
expect(result['empty']).toBe('');
|
|
290
|
+
});
|
|
268
291
|
});
|
|
269
292
|
|
|
270
293
|
describe(`${XmlParser.name} > linear scanner - skip non-element tokens`, () => {
|
|
@@ -299,6 +322,30 @@ describe(`${XmlParser.name} > linear scanner - skip non-element tokens`, () => {
|
|
|
299
322
|
const result = makeParser().parse('<root><1tag>value</root>');
|
|
300
323
|
expect(result['#text']).toBe('<1tag>value');
|
|
301
324
|
});
|
|
325
|
+
|
|
326
|
+
it('does not parse element-like tokens inside XML comments', () => {
|
|
327
|
+
const result = makeParser().parse('<root><!-- <fake>x</fake> --><name>Alice</name></root>');
|
|
328
|
+
expect(result['name']).toBe('Alice');
|
|
329
|
+
expect(Object.keys(result)).toEqual(['name']);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('does not parse element-like tokens inside processing instructions', () => {
|
|
333
|
+
const result = makeParser().parse('<root><?pi <data>x</data> ?><name>Bob</name></root>');
|
|
334
|
+
expect(result['name']).toBe('Bob');
|
|
335
|
+
expect(Object.keys(result)).toEqual(['name']);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('ignores digit-started tag even when it has a matching close tag', () => {
|
|
339
|
+
const result = makeParser().parse('<root><1>v</1><name>w</name></root>');
|
|
340
|
+
expect(result['name']).toBe('w');
|
|
341
|
+
expect(Object.keys(result)).toEqual(['name']);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('does not parse elements embedded inside a stray closing tag prefix', () => {
|
|
345
|
+
const result = makeParser().parse('<root></a<b>text</b><name>v</name></root>');
|
|
346
|
+
expect(result['name']).toBe('v');
|
|
347
|
+
expect(Object.keys(result)).toEqual(['name']);
|
|
348
|
+
});
|
|
302
349
|
});
|
|
303
350
|
|
|
304
351
|
describe(`${XmlParser.name} > linear scanner - unclosed and malformed tags`, () => {
|
|
@@ -291,4 +291,15 @@ describe(`${XmlParser.name} > manual parser edge cases`, () => {
|
|
|
291
291
|
const result = makeParser().parse('<root><item>hello <world</item></root>');
|
|
292
292
|
expect(result['item']).toBe('hello <world');
|
|
293
293
|
});
|
|
294
|
+
|
|
295
|
+
it('parses elements preceded by plain text in child content', () => {
|
|
296
|
+
const result = makeParser().parse('<root><wrap>prefix<a>v</a></wrap></root>');
|
|
297
|
+
const wrap = result['wrap'] as Record<string, unknown>;
|
|
298
|
+
expect(wrap['a']).toBe('v');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('preserves child object with multiple keys without #text flattening', () => {
|
|
302
|
+
const result = makeParser().parse('<root><w><x>1</x><y>2</y></w></root>');
|
|
303
|
+
expect(result['w']).toEqual({ x: '1', y: '2' });
|
|
304
|
+
});
|
|
294
305
|
});
|
|
@@ -162,4 +162,14 @@ describe(`${SecurityGuard.name} > extraForbiddenKeys`, () => {
|
|
|
162
162
|
expect(guard.isForbiddenKey('__proto__')).toBe(true);
|
|
163
163
|
expect(guard.isForbiddenKey('constructor')).toBe(true);
|
|
164
164
|
});
|
|
165
|
+
|
|
166
|
+
it('exposes the extra forbidden keys as a readonly array', () => {
|
|
167
|
+
const guard = new SecurityGuard(512, ['custom_a', 'custom_b']);
|
|
168
|
+
expect(guard.extraForbiddenKeys).toEqual(['custom_a', 'custom_b']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('exposes an empty readonly array when no extra keys are provided', () => {
|
|
172
|
+
const guard = new SecurityGuard();
|
|
173
|
+
expect(guard.extraForbiddenKeys).toEqual([]);
|
|
174
|
+
});
|
|
165
175
|
});
|