Limitations
Map/Set internal slots are not frozen by deepFreeze
Object.freeze on a Map or Set freezes the object shell — no new own properties — but the internal slot (the actual map/set data) is accessed through methods that live on the prototype, not as own properties. After deepFreeze(map), calling map.set(k, v) or map.add(v) succeeds silently (F2, documented).
import { deepFreeze } from 'constancy';
const m = deepFreeze(new Map([['a', 1]]));
m.set('b', 2); // WORKS — internal slot not frozen
m.get('b'); // 2Workaround: use immutableView(map) to proxy-block all mutator methods.
structuredClone domain — non-cloneable values throw
snapshot, vault, and secureSnapshot all use structuredClone internally. Values that are not in the structured clone algorithm's domain throw at call time (S3, U1):
- Functions
- DOM nodes
- Identity Symbols (symbols registered with
Symbol.forwork, bareSymbol()do not) - Class instances with non-serializable internal state (e.g.,
WeakMap,WeakSet,Promise) undefinedin non-array positions is preserved bystructuredClonebut may differ fromJSONbehavior
import { snapshot } from 'constancy';
snapshot({ fn: () => {} }); // throws DataCloneError
snapshot({ node: document.body }) // throws DataCloneError
snapshot(Symbol('x')); // throws DataCloneErrordeepFreeze and immutableView do NOT clone — they are unaffected by this limit.
tamperEvident is NOT cryptographic
The tamperEvident fingerprint uses a 64-bit djb2+sdbm hash (T1). This detects:
- Accidental mutation (bugs, unintended state drift)
- Prototype pollution (if the structural layout changes)
- In-process data corruption
This does NOT protect against:
- Adversarial hash attacks — a determined attacker with control over the input can craft a collision in ~2^16 attempts
- Pre-import poisoning — if
JSON.stringifywas overridden before the module loaded, the hash may be computed over attacker-controlled output - Replay attacks — there is no secret key, no nonce, no timestamp in the hash
- Out-of-process attacks — network MITM, file replacement, DevTools CDP
For cryptographic integrity guarantees, hash the data server-side with HMAC-SHA256 and verify the signature on the server.
// tamperEvident: good for detecting bugs, NOT for security boundaries
const vault = tamperEvident({ plan: 'free', seats: 1 });
vault.verify(); // true if structurally intact
vault.assertIntact(); // throws on mismatch
// DO NOT use fingerprint alone as a security gate
// DO validate entitlements server-side on every privileged requestimmutableView is a VIEW, not a snapshot
If you retain the original reference, mutations through it are visible through the view — and are not prevented:
import { immutableView } from 'constancy';
const raw = { count: 0 };
const view = immutableView(raw);
raw.count = 99; // WORKS — original not protected
view.count; // 99 — view reflects mutation (V8, documented)This is by design. The view only blocks mutations through the proxy reference. See VIEW vs SNAPSHOT.
secureSnapshot — plain objects only
secureSnapshot throws TypeError on any input that is not a plain object graph:
- Objects with accessor properties (
get/setdescriptors) — throws (X1 fix; previous versions silently dropped them) - Symbols without a string description — throws
- Non-plain nested objects (Date, Map, Array, class instances) — throws
- Functions — throws
import { secureSnapshot } from 'constancy';
secureSnapshot({ get x() { return 1; } }); // TypeError: accessor property
secureSnapshot({ arr: [1, 2, 3] }); // TypeError: non-plain nesteddeepFreeze and snapshot handle arrays and class instances.
Runtime support
- Node.js: ≥ 20 required (uses
structuredClone, ES2022 features) - Browsers: ES2022+ (Chrome 94+, Firefox 93+, Safari 16+, Edge 94+)
- TypeScript: any version with
"lib": ["ES2022"]or later in tsconfig
No polyfills are provided. If your environment lacks structuredClone, snapshot, vault, and secureSnapshot will throw at call time.
checkRuntimeIntegrity is best-effort, post-import only
checkRuntimeIntegrity verifies that 17 known builtins (Object.freeze, Reflect.get, Array.prototype.push, etc.) still match the references captured at module load time. It also checks the Object.prototype key-set fingerprint (I2/I5 fix in v3.0.1).
Limitations:
- Cannot detect pre-import poisoning: if a malicious script ran before
constancyloaded and overrodeObject.freeze, the cached reference is already the poisoned one (I6) - Best called at startup: the longer you wait, the more attack surface has elapsed
- Does not scan all possible attack surfaces: it covers the 17 builtins constancy itself uses; other builtins used by your app are not checked
import { checkRuntimeIntegrity } from 'constancy';
// Call this as early as possible in your app startup:
const { intact, compromised } = checkRuntimeIntegrity();
if (!intact) {
console.error('Compromised:', compromised);
// fail safely — do not proceed with sensitive operations
}TypedArray byte data is mutable after deepFreeze
Object.freeze acts on the TypedArray object shell (own properties), not on the underlying ArrayBuffer data. After deepFreeze, index-based element access still allows mutations (F3, documented):
import { deepFreeze } from 'constancy';
const arr = new Uint8Array([1, 2, 3]);
deepFreeze(arr);
arr[0] = 99; // WORKS — byte data not frozen
arr[0]; // 99There is no API in constancy that freezes TypedArray element data. Constancy skips Object.freeze on TypedArrays entirely to avoid a native runtime throw (Object.freeze on a non-empty TypedArray throws in strict mode).