diff --git a/CHANGELOG.md b/CHANGELOG.md index 36913aac..92232e9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ Change Log +v5.8.0 +--- +* Pro API: added support for custom presets. `optionsPreset` accepts the alias of a custom preset saved in the obfuscator.io dashboard; `obfuscatePro()` and the CLI (`--pro-api-token`) fetch it and merge its options +* CLI: `--options-preset` with a VM preset name (e.g. `vm-default`) no longer fails locally when `--pro-api-token` is set; the name is passed to the Pro API + v5.7.0 --- * **New option:** `advertisement` allows to control the display of the JavaScript Obfuscator Pro advertisement message in the console. Fixed https://github.com/javascript-obfuscator/javascript-obfuscator/issues/1448 diff --git a/README.md b/README.md index 9d9a8291..6b3f450e 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,29 @@ if (ProApiClient.hasProFeatures(options)) { Pro features include: - `vmObfuscation: true` – VM-based bytecode obfuscation - `parseHtml: true` – HTML parsing with inline JavaScript obfuscation +- `optionsPreset` set to a VM preset (`vm-default`, ...) or to a [custom preset](#custom-presets) alias – only the Pro API can resolve these + +### Custom Presets :new: + +Save a configuration in the [obfuscator.io](https://obfuscator.io) dashboard as a custom preset and give it an **API alias** in the save dialog. Set `optionsPreset` to that alias and `obfuscatePro()` fetches the preset's options before obfuscating, so the configuration lives in the dashboard and changes there apply to the next build: + +```javascript +const result = await JavaScriptObfuscator.obfuscatePro( + sourceCode, + { optionsPreset: 'production' }, + { apiToken: 'YOUR_API_TOKEN' } +); +``` + +The preset's options are the base and any other option you pass overrides them, the same way built-in presets are merged. A custom preset with no Pro feature enabled is obfuscated locally with its options. A team service key resolves the team owner's presets; a member's personal key resolves their own presets plus the ones shared with the team. + +An alias that does not exist for your account throws an `ApiError` with `statusCode` 404. Without an API token (`obfuscate()`), only the built-in presets are available. + +The same works from the CLI: + +```sh +javascript-obfuscator input.js --pro-api-token YOUR_API_TOKEN --options-preset production -o output.js +``` ### Error Handling @@ -455,7 +478,7 @@ javascript-obfuscator input.js --pro-api-token YOUR_API_TOKEN --pro-api-version - `--pro-api-token ` – Your API token from [obfuscator.io](https://obfuscator.io) - `--pro-api-version ` – Obfuscator.io version to use (optional, defaults to latest) -The CLI automatically detects when Pro features (`vmObfuscation` or `parseHtml`) are enabled and routes the request through the Pro API. +The CLI automatically detects when Pro features (`vmObfuscation`, `parseHtml`, or an `optionsPreset` naming a VM preset or a [custom preset](#custom-presets)) are enabled and routes the request through the Pro API. ### Large File Uploads @@ -1141,6 +1164,10 @@ Available values: * `medium-obfuscation`; * `high-obfuscation`. +With `obfuscatePro()` or `--pro-api-token` the following are also accepted and resolved by the Pro API: +* the VM presets: `vm-low-obfuscation`, `vm-default`, `vm-medium-obfuscation`, `vm-high-obfuscation`, `vm-ultra-high-obfuscation`, `vm-anti-llm`; +* the alias of a [custom preset](#custom-presets) saved in the [obfuscator.io](https://obfuscator.io) dashboard. + All addition options will be merged with selected options preset. ### `renameGlobals` @@ -1924,8 +1951,8 @@ Specify exactly which root-level functions should get VM protection by name. **Example:** ```javascript { - vmObfuscation: true, - vmTargetFunctions: ['someFunctionName'] + vmObfuscation: true, + vmTargetFunctions: ['someFunctionName'] } ``` @@ -1939,8 +1966,8 @@ Specify root-level functions that should never get VM protection. Takes preceden **Example:** ```javascript { - vmObfuscation: true, - vmExcludeFunctions: ['someFunctionName'] + vmObfuscation: true, + vmExcludeFunctions: ['someFunctionName'] } ``` @@ -1960,28 +1987,28 @@ Controls how functions/methods are selected for VM obfuscation. ```javascript // Source code function regularFunction() { - return 'not virtualized'; + return 'not virtualized'; } /* javascript-obfuscator:vm */ function sensitiveFunction() { - return 'this will be VM-protected'; + return 'this will be VM-protected'; } function outer() { - /* javascript-obfuscator:vm */ - function nestedSensitive() { - return 'nested but still VM-protected'; - } - return nestedSensitive(); + /* javascript-obfuscator:vm */ + function nestedSensitive() { + return 'nested but still VM-protected'; + } + return nestedSensitive(); } ``` ```javascript // Obfuscator options { - vmObfuscation: true, - vmTargetFunctionsMode: 'comment' + vmObfuscation: true, + vmTargetFunctionsMode: 'comment' } ``` @@ -2042,6 +2069,8 @@ const MY_STRING = (() => { return /* VM bytecode call */ })(); // String hidden **Note:** This option only works when `vmTargetFunctionsMode` is `'root'` (the default). +**Warnings:** Whenever a top-level initializer ends up in plain JavaScript under VM obfuscation, a `VMTopLevelInitializerNotVirtualized` warning listing the affected variable names is reported. That covers: this option being disabled, initializers this option had to skip (each with the reason — e.g. the initializer references a sibling declarator or contains top-level await), and `vmAsyncExecutor` mode where the synchronous wrappers can't be virtualized at all. + ### `vmDynamicOpcodes` Type: `boolean` Default: `false` @@ -2056,7 +2085,7 @@ As the result - smaller output and each build looks different. ### `vmBytecodeEncoding` Type: `boolean` Default: `false` -Encodes each bytecode instruction. Instructions are decoded one at a time during execution. +Encodes each bytecode instruction (decoded one at a time during execution) and masks the string constants stored in the bytecode pool, so plaintext strings do not sit in the compiled bytecode. ### `vmBytecodeArrayEncoding` Type: `boolean` Default: `false` @@ -2075,11 +2104,15 @@ This option externalizes the encryption key - it's not embedded in the obfuscate ### `vmBytecodeArrayEncodingKeyGetter` Type: `string` Default: `''` -**Synchronous** JavaScript expression that **returns** the encryption key at runtime. This expression is evaluated when the obfuscated code loads, and must return the same key that was provided in `vmBytecodeArrayEncodingKey`. +**Synchronous** JavaScript expression that **returns** the encryption key at runtime. This expression is evaluated when the obfuscated code loads, and must return the same key that was provided in `vmBytecodeArrayEncodingKey`. To resolve the key **asynchronously** (a `Promise`), enable [`vmAsyncExecutor`](#vmasyncexecutor). -**The obfuscated code will only work when the key getter returns exactly the same key that was used during obfuscation.** If the keys don't match, decryption will fail and the code will produce garbage or errors. If the key getter returns `undefined`, `null`, or an empty string, the code will throw an error: "VM decryption key not available". +> **Note:** a Promise-returning getter requires `vmAsyncExecutor`. This can't be checked at build time, so a Promise getter with `vmAsyncExecutor` **off** fails at runtime — the decoder receives the Promise instead of the key. -**Important:** The key should NOT be defined in the same JavaScript file/script as the obfuscated code. Doing so defeats the purpose of key externalization, as static analysis could still find the key. Store the key in a separate source: server-set cookies, localStorage populated by another script, server-injected HTML meta tags, or a global variable set by a different script that loads before the obfuscated code. +**The obfuscated code will only work when the key getter returns exactly the same key that was used during obfuscation.** If the keys don't match — or the getter returns `undefined`, `null`, or an empty string — decryption produces a wrong keystream and the code fails at runtime with garbage output or an ordinary runtime error. There is deliberately no distinct, key-specific error message, so a failed key is indistinguishable from any other runtime fault. + +**Important:** Keep the key out of the same file/script as the obfuscated code — inlining it there lets even a purely **static** scan of the bundle recover it. Store it in a separate source instead: server-set cookies, `localStorage` populated by another script, a server-injected HTML meta tag, a global set by a different script, or (with [`vmAsyncExecutor`](#vmasyncexecutor)) fetched from your backend at runtime. + +When the key is fetched from your backend (via [`vmAsyncExecutor`](#vmasyncexecutor)), add session- or origin-based checks on that endpoint: return the correct key to real users (valid session, expected `Origin`/`Referer`) and a garbage key to suspicious requests (e.g. a `localhost`/unexpected origin, no session). Real users run normally; a copy running outside your environment gets a key that decrypts to nothing. The exact logic depends on your site. Examples: ```ts @@ -2097,16 +2130,19 @@ vmBytecodeArrayEncodingKeyGetter: "document.querySelector('meta[name=\"vm-key\"] // From nested object vmBytecodeArrayEncodingKeyGetter: "window.config.encryption.key" + +// From backend, async (requires vmAsyncExecutor) +vmBytecodeArrayEncodingKeyGetter: 'fetch("/vm-key").then((res) => res.text())' ``` **Usage example:** ```ts // Build time JavaScriptObfuscator.obfuscate(code, { - vmObfuscation: true, - vmBytecodeArrayEncoding: true, - vmBytecodeArrayEncodingKey: 'mySecretKey123', - vmBytecodeArrayEncodingKeyGetter: 'window.__VM_KEY__' + vmObfuscation: true, + vmBytecodeArrayEncoding: true, + vmBytecodeArrayEncodingKey: 'mySecretKey123', + vmBytecodeArrayEncodingKeyGetter: 'window.__VM_KEY__' }); // Runtime - key must be set before obfuscated code runs @@ -2156,25 +2192,36 @@ Type: `boolean` Default: `false` Encodes jump targets in the bytecode. Jump offsets are calculated at runtime, hiding the control flow structure (`if`/`else`, loops, etc.) from static analysis. -### `vmDecoyOpcodes` +### `vmMacroOps` Type: `boolean` Default: `false` -Adds fake opcode handlers to the VM dispatcher that are never called. For example, if the VM uses 20 real opcodes, this might add 30 fake handlers, making the interpreter appear more complex than it really is. +Combines common instruction sequences into single "macro" opcodes. For example, `LOAD_ARG + PUSH_CONST + SUB` can become `MACRO_SUB_ARG_CONST`, reducing interpreter dispatches. This works with both the default stack VM and `vmRegisterBased: true`; enable `vmMacroOps: true` explicitly in either mode. -### `vmDeadCodeInjection` -Type: `boolean` Default: `false` +### `vmDebugProtection` +Type: `boolean | object` Default: `false` -Injects fake bytecode sequences that are never executed. These look like real instructions but are skipped during runtime, confusing analysis tools that process them. +Adds multi-layered anti-debugging, anti-analysis, and anti-LLM defenses to the VM runtime. Works best with `browser`/`browser-no-eval` targets. -### `vmMacroOps` -Type: `boolean` Default: `false` +Pass `true` to enable it, or `false` to disable it. Pass an object to enable it while turning off a specific defense: -Combines common instruction sequences into single "macro" opcodes. For example, `LOAD + ADD + STORE` might become a single `MACRO_ADD_TO_VAR` instruction. This breaks pattern recognition and can improve performance. +```js +{ + vmDebugProtection: { + // most defenses are always on; but CDP/devtools detection is disabled + inspectorDetection: false + } +} +``` -### `vmDebugProtection` -Type: `boolean` Default: `false` +> :warning: **The object is not a menu of defenses to switch on.** When debug protection is enabled, the great majority of its defenses are **always active and cannot be turned off**. The sub-options below expose only the small number of defenses that some consumers may deliberately need to relax (for example, because a false positive would break a legitimate workflow) — every other defense stays on regardless. + +| Sub-option | Type | Default | Description | +| --- | --- | --- | --- | +| `inspectorDetection` | `boolean` | `true` | Detect and react to an attached CDP (Chrome DevTools Protocol) inspector — both the CDP `Runtime` domain being enabled (`Runtime.enable`) and an active debugger (the CDP `Debugger` domain, e.g. breakpoints or the developer-tools Sources panel). **Opening the browser's developer tools enables those domains, so an open inspector is detected and reacted to.** Keeping it enabled is recommended; set it to `false` only if your users legitimately open developer tools. | -Adds multi-layered anti-debugging, anti-analysis, and anti-LLM defenses to the VM runtime. For best results, allow `unsafe-eval` in your Content Security Policy. Works best with `browser`/`browser-no-eval` targets. +> :bulb: The object form is available through the API + +> :warning: **Automation frameworks.** With `inspectorDetection` on (the default), driving the protected page with a CDP-based tool (Puppeteer, Playwright, Selenium/ChromeDriver) is detected as an attached inspector. If you run automated tests against protected code, build those with `vmDebugProtection: { inspectorDetection: false }`. ### `vmSelfDefending` Type: `boolean` Default: `false` @@ -2183,12 +2230,21 @@ Adds multi-layered tamper detection, anti-hooking, and anti-reverse-engineering > :warning: This option force-enables [`vmBytecodeArrayEncoding`](#vmbytecodeArrayEncoding). +> :warning: **Sensitive environment detection.** This option binds the obfuscated code to its target runtime environment and uses advanced browser fingerprinting to detect automation tools. Code protected with this option **will intentionally break** when run in: +> - Headless browsers (headless Chrome/Chromium, PhantomJS) +> - Browser automation tools (Puppeteer, Playwright, Cypress, Selenium/ChromeDriver, Nightmare) +> - Node.js (when `target` is set to `browser`) +> - jsdom or similar server-side DOM emulations +> - Environments where native browser builtins have been hooked or replaced +> +> The code **will work correctly** in regular browsers (Chrome, Firefox, Safari, Edge), including when loaded inside iframes, browser extensions (content scripts), and Web Workers. If you need to run automated tests against protected code, disable `vmSelfDefending` for test builds — this option is designed to prevent automated analysis and **cannot be safely used with any automation framework**. + Strongly recommended to use together with [`vmDebugProtection`](#vmDebugProtection), [`vmBytecodeArrayEncodingKey`](#vmbytecodeArrayEncodingKey), and [`vmBytecodeArrayEncodingKeyGetter`](#vmbytecodeArrayEncodingKeyGetter). ### `vmDefenseHook` -Type: `{ name: string, aliases?: object }` Default: `''` +Type: `{ name: string, aliases?: object } | null` Default: `null` -`vmDefenseHook` takes an object with two keys: **`name`** (required) and **`aliases`** (optional). +`vmDefenseHook` is `null` (disabled) or an object with two keys: **`name`** (required) and **`aliases`** (optional). `name` is a **global function your host page defines** that a VM defense (`vmDebugProtection` / `vmSelfDefending`) calls with a signal object when it detects a hostile signal — a debugger or inspector, a headless / automation browser, an AI-coding-agent process, a disallowed domain, and so on. Use it to report the event to your backend (e.g. `navigator.sendBeacon`). The hook is a **pure telemetry sink**: its return value is ignored, and a missing or throwing hook is a silent no-op that can never disable a defense. To change what a defense *does* on detection, use [`vmDefenseReaction`](#vmdefensereaction). @@ -2248,8 +2304,6 @@ vmDefenseHook: { This is fingerprint avoidance, not secrecy — the mapping can still be inferred by repeated testing — so its only benefit is not exposing stable, self-explanatory names. Unset entries keep their default names. -> A bare string (`vmDefenseHook: '__vmDetection'`) is accepted as shorthand for `{ name: '__vmDetection' }` but is **deprecated** — prefer the object form. - ### `vmDefenseReaction` Type: `object` Default: `{ automation: 'break', debugger: 'decoy', sandbox: 'decoy', domain: 'break', tamper: 'break', integrity: 'break' }` @@ -2284,17 +2338,33 @@ vmDefenseReaction: { automation: 'none', domain: 'break' } // tolerate automat ### `browserEnvironment` Type: `object` Default: `{}` -Declares facts about the environment your production build is served in, so the protected code can bind itself to them. Only takes effect together with [`vmSelfDefending`](#vmselfdefending), and only for `browser` / `browser-no-eval` / `service-worker` targets — it is rejected for `node`, `userscript`, and `bytenode`. +Declares facts about the environment your production build runs in, so the protected code can bind to, react to, or tolerate them. Available only for `browser` / `browser-no-eval` / `service-worker` targets — it is rejected for `node`, `userscript`, and `bytenode`. Each field takes effect together with a specific protection, noted below. -Currently one field: +Fields: -- **`transport`** — the scheme your production serves the bundle over: `'http'` or `'https'`. With `'https'`, the build ties its integrity to being served over HTTPS, so a copy an analyst lifts and serves over plain HTTP (a common local reverse-engineering setup) will not run correctly. `'http'` or an unset field adds no binding. +- **`transport`** — the scheme your production serves the bundle over: `'http'` or `'https'`. With `'https'`, the build ties its integrity to being served over HTTPS, so a copy an analyst lifts and serves over plain HTTP (a common local reverse-engineering setup) will not run correctly. `'http'` or an unset field adds no binding. Takes effect with [`vmSelfDefending`](#vmselfdefending). ```js browserEnvironment: { transport: 'https' } ``` -> :warning: A build declared `transport: 'https'` runs correctly **only** where it is actually served over `https:`. Every other scheme corrupts it, so declare it only when every context that loads your production build is HTTPS. That excludes: plain `http://` (including `http://localhost` in development), `file://` (Electron / Cordova / packaged apps), and `blob:` / `about:` embeddings (a bundle running inside an `about:blank` or `srcdoc` iframe). A client-side HTTP→HTTPS redirect still renders the HTTP page first, so the bundle must not run before the redirect completes. +- **`hosting`** — where your production bundle is served from: `'remote'` or `'local'`. With `'remote'`, the build ties its integrity to being served from a remote host, so a copy an analyst lifts and runs in their own local setup is treated as a runtime-environment mismatch and the automation defenses react (see [`vmDebugProtection`](#vmdebugprotection) and [`vmDefenseReaction`](#vmdefensereaction)). `'local'` or an unset field adds no binding. Takes effect with `vmDebugProtection`, on `browser` / `browser-no-eval` only. + +```js +browserEnvironment: { transport: 'https', hosting: 'remote' } +``` + +- **`hookedBuiltins`** — set to `true` to declare that the runtime your production build runs in legitimately replaces native builtins with JavaScript wrappers: the app's own anti-tamper, the host page, or other browser extensions sharing the same realm. [`vmSelfDefending`](#vmselfdefending) normally treats a replaced native builtin as tampering and stops the build from running; with this set, it tolerates such an environment and the code runs. `false` or an unset field keeps the strict behavior. Takes effect with [`vmSelfDefending`](#vmselfdefending). + +```js +browserEnvironment: { hookedBuiltins: true } +``` + +This option relaxes nativity checks only; clean-realm validation and required builtin behavior remain enforced. + +> :warning: `hookedBuiltins` deliberately relaxes tamper detection: once it is set, an analyst who wraps those same builtins to inspect your code is no longer stopped either. The VM virtualization, anti-debugging, and integrity protections are unaffected. Enable it only when your production runtime is known to hook builtins and that weaker guarantee is acceptable. + +> :warning: The `transport` and `hosting` fields bind the protected build to the environment you declare. The same build loaded in any environment that does not match — including transiently, before it reaches its final one — will not run correctly, by design. Declare a field only when every context that loads your production build matches it, and keep these declarations off the builds you use for local development, testing, and CI. ### `vmStatefulOpcodes` Type: `boolean` Default: `false` @@ -2353,7 +2423,6 @@ When enabled, the string array will **only** extract strings from bytecode data - When `vmBytecodeArrayEncoding: true` — top-level base64 encoded bytecode strings are extracted - `stringArrayThreshold` still controls what percentage of those bytecode strings are extracted - ### `vmDomainLock` Type: `string[]` Default: `[]` @@ -2373,81 +2442,6 @@ Type: `string` Default: `about:blank` Allows the browser to be redirected to a passed URL if the source code isn't run on the domains specified by [`vmDomainLock`](#vmdomainlock). -### `strictMode` -Type: `boolean | null` Default: `null` - -Allows to specify how the obfuscator should treat code regarding JavaScript strict mode. - -Available values: -* `null` (default) - auto-detect strict mode from the code. If the code has explicit `'use strict'` directive, ES module syntax, or class methods, it's treated as strict mode. Otherwise, sloppy mode is assumed. -* `true` - force strict mode treatment for all code, even without explicit `'use strict'` directive. Use this when your code will run in strict mode context (e.g., in ES modules, bundlers, or modern frameworks). -* `false` - only explicit strict mode indicators (`'use strict'`, ES modules, class methods) are treated as strict. Parent scope inheritance still applies per JS spec. - -### `parseHtml` -Type: `boolean` Default: `false` - -Enables obfuscation of JavaScript within HTML ` - - - - -`; - -JavaScriptObfuscator.obfuscate(html, { - parseHtml: true, - stringArray: true -}); - -// output: HTML with only the marked script obfuscated -``` - -### `randomIdentifiersPrefix` -Type: `boolean` Default: `false` - -Appends a seeded random prefix (6 alphanumeric characters) to all global identifiers. Use this option to avoid collisions between separately obfuscated bundles that are loaded into the same global scope — it removes the need to pick a unique `identifiersPrefix` per bundle manually. - -- The random value is derived from the `seed` option and the source code hash, so reproducible builds with the same seed produce the same prefix. -- When combined with `identifiersPrefix`, the random characters are appended to the user-provided prefix (e.g. `myApp` + random `aBc123` → `myAppaBc123`). -- When combined with `vmObfuscation`, the random value replaces the default `vm` prefix — randomness already guarantees uniqueness. - ## Frequently Asked Questions ### What javascript versions are supported? diff --git a/package.json b/package.json index cacb5db9..51711b5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "javascript-obfuscator", - "version": "5.7.0", + "version": "5.8.0", "description": "JavaScript obfuscator", "keywords": [ "obfuscator", diff --git a/src/JavaScriptObfuscatorFacade.ts b/src/JavaScriptObfuscatorFacade.ts index 2e05248d..bebbb148 100644 --- a/src/JavaScriptObfuscatorFacade.ts +++ b/src/JavaScriptObfuscatorFacade.ts @@ -115,13 +115,14 @@ class JavaScriptObfuscatorFacade { const { ProApiClient } = await import('./pro-api/ProApiClient'); - if (!ProApiClient.hasProFeatures(inputOptions)) { - return JavaScriptObfuscatorFacade.obfuscate(sourceCode, inputOptions); - } - const client = new ProApiClient(proApiConfig); + const options: TInputOptions = await client.resolveOptions(inputOptions); + + if (!ProApiClient.hasProFeatures(options)) { + return JavaScriptObfuscatorFacade.obfuscate(sourceCode, options); + } - return client.obfuscate(sourceCode, inputOptions, onProgress); + return client.obfuscate(sourceCode, options, onProgress); } } diff --git a/src/cli/JavaScriptObfuscatorCLI.ts b/src/cli/JavaScriptObfuscatorCLI.ts index 08747a88..380fc7d6 100644 --- a/src/cli/JavaScriptObfuscatorCLI.ts +++ b/src/cli/JavaScriptObfuscatorCLI.ts @@ -4,7 +4,6 @@ import * as path from 'path'; import { TInputCLIOptions } from '../types/options/TInputCLIOptions'; import { TInputOptions } from '../types/options/TInputOptions'; -import { TOptionsPreset } from '../types/options/TOptionsPreset'; import { IFileData } from '../interfaces/cli/IFileData'; import { IInitializable } from '../interfaces/IInitializable'; @@ -92,6 +91,11 @@ export class JavaScriptObfuscatorCLI implements IInitializable { @initializable() private obfuscatedCodeFileUtils!: ObfuscatedCodeFileUtils; + /** + * @type {ProApiClient | undefined} + */ + private proApiClient?: ProApiClient; + /** * @type {string[]} */ @@ -121,9 +125,11 @@ export class JavaScriptObfuscatorCLI implements IInitializable { const configFileLocation: string = configFilePath ? path.resolve(configFilePath, '.') : ''; const configFileOptions: TInputOptions = configFileLocation ? CLIUtils.getUserConfig(configFileLocation) : {}; - const presetName: TOptionsPreset = + const presetName: string = inputCLIOptions.optionsPreset ?? configFileOptions.optionsPreset ?? OptionsPreset.Default; - const presetOptions: TInputOptions = Options.getOptionsByPreset(presetName); + const presetOptions: TInputOptions = Options.isLocalPreset(presetName) + ? Options.getOptionsByPreset(presetName) + : { optionsPreset: presetName }; return { ...presetOptions, @@ -275,7 +281,9 @@ export class JavaScriptObfuscatorCLI implements IInitializable { .option( '--options-preset ', 'Allows to set options preset. ' + - `Values: ${CLIUtils.stringifyOptionAvailableValues(OptionsPreset)}. ` + + `Values: ${CLIUtils.stringifyOptionAvailableValues(OptionsPreset)}, ` + + 'a Pro VM preset (e.g. vm-default) or the alias of a custom preset saved at obfuscator.io ' + + '(both require --pro-api-token). ' + `Default: ${OptionsPreset.Default}` ) .option( @@ -665,7 +673,7 @@ export class JavaScriptObfuscatorCLI implements IInitializable { outputCodePath: string, sourceCodeIndex: number | null ): Promise { - const options: TInputOptions = { + let options: TInputOptions = { ...this.inputCLIOptions, identifierNamesCache: this.identifierNamesCacheFileUtils.readFile(), inputFileName: path.basename(inputCodePath), @@ -680,9 +688,20 @@ export class JavaScriptObfuscatorCLI implements IInitializable { const proApiToken = this.inputCLIOptions.proApiToken; if (proApiToken && ProApiClient.hasProFeatures(options)) { - await this.processSourceCodeWithProApi(sourceCode, outputCodePath, options, proApiToken); + const client: ProApiClient = this.getProApiClient(proApiToken); - return; + options = await client.resolveOptions(options); + + if (ProApiClient.hasProFeatures(options)) { + await this.processSourceCodeWithProApi({ + sourceCode: sourceCode, + outputCodePath: outputCodePath, + options: options, + client: client + }); + + return; + } } if (options.sourceMap) { @@ -693,21 +712,38 @@ export class JavaScriptObfuscatorCLI implements IInitializable { } /** - * Process source code using Pro API (cloud-based VM obfuscation) + * @param {string} apiToken + * @return {ProApiClient} + * @private */ - private async processSourceCodeWithProApi( - sourceCode: string, - outputCodePath: string, - options: TInputOptions, - apiToken: string - ): Promise { - const proApiVersion = this.inputCLIOptions.proApiVersion; + private getProApiClient(apiToken: string): ProApiClient { + if (!this.proApiClient) { + this.proApiClient = new ProApiClient({ + apiToken, + version: this.inputCLIOptions.proApiVersion + }); + } - const client = new ProApiClient({ - apiToken, - version: proApiVersion - }); + return this.proApiClient; + } + /** + * @param {{sourceCode: string, outputCodePath: string, options: TInputOptions, client: ProApiClient}} param0 + * @private + */ + private async processSourceCodeWithProApi( + { + sourceCode, + outputCodePath, + options, + client + }: { + sourceCode: string; + outputCodePath: string; + options: TInputOptions; + client: ProApiClient; + } + ): Promise { const result: IProObfuscationResult = await client.obfuscate(sourceCode, options, (message: string) => { Logger.log(Logger.colorInfo, LoggingPrefix.CLI, message); }); diff --git a/src/interfaces/pro-api/IProApiClient.ts b/src/interfaces/pro-api/IProApiClient.ts index 77d13273..710fa2a4 100644 --- a/src/interfaces/pro-api/IProApiClient.ts +++ b/src/interfaces/pro-api/IProApiClient.ts @@ -1,3 +1,4 @@ +import { TInputOptions } from '../../types/options/TInputOptions'; import { TIdentifierNamesCache } from '../../types/TIdentifierNamesCache'; /** @@ -48,6 +49,18 @@ export interface IProApiConfig { version?: string; } +/** + * A custom preset saved in the obfuscator.io dashboard, as returned by + * `GET /api/v1/presets/{alias}` + */ +export interface IProCustomPreset { + alias: string; + name: string; + description: string | null; + options: TInputOptions; + updatedAt: string; +} + /** * Progress callback for streaming responses */ @@ -89,11 +102,3 @@ export interface IProApiStreamMessage { /** Total number of chunks (for 'chunk' type) */ total?: number; } - -/** - * Response from the Blob upload endpoint - */ -export interface IProApiBlobUploadResponse { - blobUrl?: string; - error?: string; -} diff --git a/src/options/Options.ts b/src/options/Options.ts index 395726c3..838489a6 100644 --- a/src/options/Options.ts +++ b/src/options/Options.ts @@ -471,13 +471,27 @@ export class Options implements IOptions { * @param {TOptionsPreset} optionsPreset * @returns {TInputOptions} */ - public static getOptionsByPreset(optionsPreset: TOptionsPreset): TInputOptions { - const options: TInputOptions | null = Options.optionPresetsMap.get(optionsPreset) ?? null; + public static getOptionsByPreset(optionsPreset: string): TInputOptions { + const options: TInputOptions | null = Options.optionPresetsMap.get(optionsPreset) ?? null; if (!options) { - throw new Error(`Options for preset name \`${optionsPreset}\` are not found`); + throw new Error( + `Options for preset name \`${optionsPreset}\` are not found. ` + + 'VM presets and custom preset aliases are resolved by the Pro API: ' + + 'use `obfuscatePro()` or the `--pro-api-token` CLI option.' + ); } return options; } + + /** + * Whether a preset name is one this package can expand without the Pro API. + * + * @param {string | undefined} optionsPreset + * @returns {boolean} + */ + public static isLocalPreset(optionsPreset: string | undefined): boolean { + return optionsPreset !== undefined && Options.optionPresetsMap.has(optionsPreset); + } } diff --git a/src/pro-api/ProApiClient.ts b/src/pro-api/ProApiClient.ts index 1da6f7ed..81f0eff8 100644 --- a/src/pro-api/ProApiClient.ts +++ b/src/pro-api/ProApiClient.ts @@ -2,11 +2,14 @@ import { TInputOptions } from '../types/options/TInputOptions'; import { IProApiConfig, IProApiStreamMessage, + IProCustomPreset, IProObfuscationResult, TProApiProgressCallback } from '../interfaces/pro-api/IProApiClient'; import { ApiError } from './ApiError'; import { ProApiObfuscationResult } from './ProApiObfuscationResult'; +import { ProOptionsPreset } from './enums/ProOptionsPreset'; +import { Options } from '../options/Options'; /** * Pro API Client @@ -23,6 +26,10 @@ export class ProApiClient { private static readonly uploadTokenUrl = `${ProApiClient.apiHost}/api/v1/upload/token`; + private static readonly presetsUrl = `${ProApiClient.apiHost}/api/v1/presets`; + + private static readonly builtInPresets: ReadonlySet = new Set(Object.values(ProOptionsPreset)); + /** * Default timeout (5 minutes) */ @@ -45,6 +52,8 @@ export class ProApiClient { version?: string; }; + private readonly presetRequests: Map> = new Map(); + public constructor(config: IProApiConfig) { this.config = { apiToken: config.apiToken, @@ -55,10 +64,65 @@ export class ProApiClient { /** * Check if any Pro features are enabled in the options. - * Pro features require the Pro API for cloud-based obfuscation. + * Pro features require the Pro API for cloud-based obfuscation. A preset + * name the local obfuscator cannot expand (a VM preset, or a custom + * preset alias) counts too: only the Pro API can resolve it. */ public static hasProFeatures(options: TInputOptions): boolean { - return options.vmObfuscation === true || options.parseHtml === true; + return ( + options.vmObfuscation === true || + options.parseHtml === true || + (typeof options.optionsPreset === 'string' && !Options.isLocalPreset(options.optionsPreset)) + ); + } + + /** + * Whether an `optionsPreset` value is one the obfuscator itself + * understands. Anything else names a custom preset saved in the dashboard. + */ + public static isBuiltInPreset(optionsPreset: string | undefined): boolean { + return optionsPreset !== undefined && ProApiClient.builtInPresets.has(optionsPreset); + } + + /** + * Fetch a custom preset by its alias, or null when the caller has no + * preset with that alias. + * @param alias - The alias set in the dashboard's save dialog + */ + public async fetchPreset(alias: string): Promise { + let request = this.presetRequests.get(alias); + + if (!request) { + request = this.requestPreset(alias); + this.presetRequests.set(alias, request); + } + + return request; + } + + /** + * Options with a custom `optionsPreset` expanded: the preset's saved + * options become the base and the caller's other options override them, + * the same base-then-overrides order the obfuscator applies to a built-in + * preset. A built-in `optionsPreset` (or none) is returned untouched, with + * no request made. + * @param options - Obfuscation options, possibly naming a custom preset + * @throws {ApiError} 404 when the alias names no preset of the caller's + */ + public async resolveOptions(options: TInputOptions): Promise { + const { optionsPreset, ...rest } = options; + + if (typeof optionsPreset !== 'string' || ProApiClient.isBuiltInPreset(optionsPreset)) { + return options; + } + + const preset = await this.fetchPreset(optionsPreset); + + if (!preset) { + throw new ApiError(`Custom preset "${optionsPreset}" not found`, 404); + } + + return { ...preset.options, ...rest }; } /** @@ -275,6 +339,60 @@ export class ProApiClient { } } + /** + * GET /api/v1/presets/{alias}. Same shape as getUploadToken: JSON body, + * `{ error }` on failure, the request timeout mapped to 408. + */ + private async requestPreset(alias: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + try { + const response = await fetch(`${ProApiClient.presetsUrl}/${encodeURIComponent(alias)}`, { + method: 'GET', + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'Authorization': `Bearer ${this.config.apiToken}` + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (response.status === 404) { + return null; + } + + const responseText = await response.text(); + + let data: (IProCustomPreset & { error?: string }) | { error?: string }; + + try { + data = JSON.parse(responseText); + } catch { + throw new ApiError(responseText || 'Failed to fetch preset', response.status); + } + + if (!response.ok) { + throw new ApiError(data.error ?? 'Failed to fetch preset', response.status); + } + + return data; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof ApiError) { + throw error; + } + + if (error instanceof Error && error.name === 'AbortError') { + throw new ApiError('Preset request timeout', 408); + } + + throw error; + } + } + /** * Upload file directly to Vercel Blob using client token */ diff --git a/src/pro-api/enums/ProOptionsPreset.ts b/src/pro-api/enums/ProOptionsPreset.ts new file mode 100644 index 00000000..3518615e --- /dev/null +++ b/src/pro-api/enums/ProOptionsPreset.ts @@ -0,0 +1,25 @@ +import { Utils } from '../../utils/Utils'; + +export const ProOptionsPreset: Readonly<{ + Default: 'default'; + LowObfuscation: 'low-obfuscation'; + MediumObfuscation: 'medium-obfuscation'; + HighObfuscation: 'high-obfuscation'; + VMLowObfuscation: 'vm-low-obfuscation'; + VMDefault: 'vm-default'; + VMMediumObfuscation: 'vm-medium-obfuscation'; + VMHighObfuscation: 'vm-high-obfuscation'; + VMUltraHighObfuscation: 'vm-ultra-high-obfuscation'; + VMAntiLLM: 'vm-anti-llm'; +}> = Utils.makeEnum({ + Default: 'default', + LowObfuscation: 'low-obfuscation', + MediumObfuscation: 'medium-obfuscation', + HighObfuscation: 'high-obfuscation', + VMLowObfuscation: 'vm-low-obfuscation', + VMDefault: 'vm-default', + VMMediumObfuscation: 'vm-medium-obfuscation', + VMHighObfuscation: 'vm-high-obfuscation', + VMUltraHighObfuscation: 'vm-ultra-high-obfuscation', + VMAntiLLM: 'vm-anti-llm' +}); diff --git a/src/types/options/TInputCLIOptions.ts b/src/types/options/TInputCLIOptions.ts index b2d2f5cf..242d9462 100644 --- a/src/types/options/TInputCLIOptions.ts +++ b/src/types/options/TInputCLIOptions.ts @@ -1,5 +1,8 @@ import { TDictionary } from '../TDictionary'; +import { TInputOptionsPreset } from './TInputOptionsPreset'; import { ICLIOptions } from '../../interfaces/options/ICLIOptions'; -export type TInputCLIOptions = Partial> & TDictionary; +export type TInputCLIOptions = Partial> & { + optionsPreset?: TInputOptionsPreset; +} & TDictionary; diff --git a/src/types/options/TInputOptions.ts b/src/types/options/TInputOptions.ts index 34325eb5..e1a01783 100644 --- a/src/types/options/TInputOptions.ts +++ b/src/types/options/TInputOptions.ts @@ -1,5 +1,8 @@ import { TDictionary } from '../TDictionary'; +import { TInputOptionsPreset } from './TInputOptionsPreset'; import { IOptions } from '../../interfaces/options/IOptions'; -export type TInputOptions = Partial> & TDictionary; +export type TInputOptions = Partial> & { + optionsPreset?: TInputOptionsPreset; +} & TDictionary; diff --git a/src/types/options/TInputOptionsPreset.ts b/src/types/options/TInputOptionsPreset.ts new file mode 100644 index 00000000..6bfbdff0 --- /dev/null +++ b/src/types/options/TInputOptionsPreset.ts @@ -0,0 +1,4 @@ +import { TOptionsPreset } from './TOptionsPreset'; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types +export type TInputOptionsPreset = TOptionsPreset | (string & {}); diff --git a/test/functional-tests/cli/JavaScriptObfuscatorCLI.spec.ts b/test/functional-tests/cli/JavaScriptObfuscatorCLI.spec.ts index 6f7ae029..ed3ca0c6 100644 --- a/test/functional-tests/cli/JavaScriptObfuscatorCLI.spec.ts +++ b/test/functional-tests/cli/JavaScriptObfuscatorCLI.spec.ts @@ -13,6 +13,7 @@ import { StdoutWriteMock } from '../../mocks/StdoutWriteMock'; import { AdvertisementUtils } from '../../../src/utils/AdvertisementUtils'; import { JavaScriptObfuscatorCLI } from '../../../src/JavaScriptObfuscatorCLIFacade'; import { ProApiClient } from '../../../src/pro-api/ProApiClient'; +import { ApiError } from '../../../src/pro-api/ApiError'; import { parseSourceMapFromObfuscatedCode } from '../../helpers/parseSourceMapFromObfuscatedCode'; describe('JavaScriptObfuscatorCLI', function (): void { @@ -1591,6 +1592,232 @@ describe('JavaScriptObfuscatorCLI', function (): void { }); }); + describe('`--pro-api-token` with a preset the local obfuscator cannot expand', () => { + const PRESETS_URL = 'https://obfuscator.io/api/v1/presets/'; + const OBFUSCATE_URL = 'https://obfuscator.io/api/v1/obfuscate'; + + let fetchStub: sinon.SinonStub; + let presetFilePath: string; + let presetDirPath: string; + let configFilePath: string; + + /** + * Answers the presets endpoint with a saved preset (or 404) and the + * obfuscate endpoint with a fixed result; returns the request log. + */ + const stubEndpoints = (presetOptions: object | null) => { + const calls: { url: string; options: object | undefined }[] = []; + + fetchStub = sinon.stub(global, 'fetch').callsFake(async (url: unknown, init?: RequestInit) => { + const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + + calls.push({ url: String(url), options: body?.options }); + + if (String(url).startsWith(PRESETS_URL)) { + return { + ok: presetOptions !== null, + status: presetOptions !== null ? 200 : 404, + text: async () => + JSON.stringify( + presetOptions !== null + ? { alias: 'production', name: 'Production', description: null, options: presetOptions, updatedAt: '' } + : { error: 'Preset not found' } + ) + } as Response; + } + + return { + ok: true, + status: 200, + text: async () => JSON.stringify({ type: 'result', code: 'var obfuscated=1;', sourceMap: '' }) + } as Response; + }); + + return calls; + }; + + before(() => { + presetFilePath = path.join(outputDirName, 'preset-test.js'); + fs.writeFileSync(presetFilePath, 'function f() { const a = 1; return a; }'); + + presetDirPath = path.join(outputDirName, 'preset-dir'); + fs.mkdirSync(presetDirPath, { recursive: true }); + fs.writeFileSync(path.join(presetDirPath, 'one.js'), 'const one = 1;'); + fs.writeFileSync(path.join(presetDirPath, 'two.js'), 'const two = 2;'); + + configFilePath = path.join(outputDirName, 'preset-config.json'); + fs.writeFileSync(configFilePath, JSON.stringify({ optionsPreset: 'vm-default', target: 'browser' })); + }); + + afterEach(() => { + if (fetchStub) { + fetchStub.restore(); + } + }); + + after(() => { + fs.rmSync(presetFilePath, { force: true }); + fs.rmSync(presetDirPath, { recursive: true, force: true }); + fs.rmSync(configFilePath, { force: true }); + }); + + describe('Variant #1: custom preset alias', () => { + it('should fetch the preset and obfuscate through the Pro API with its options', async () => { + const outputPath = path.join(outputDirName, 'preset-output1.js'); + const calls = stubEndpoints({ vmObfuscation: true, optionsPreset: 'vm-default', compact: false }); + + await JavaScriptObfuscatorCLI.obfuscate([ + 'node', + 'javascript-obfuscator', + presetFilePath, + '--output', + outputPath, + '--pro-api-token', + 'test-token-123', + '--options-preset', + 'production', + '--compact', + 'true' + ]); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.deepEqual( + calls.map((call) => call.url), + [`${PRESETS_URL}production`, OBFUSCATE_URL] + ); + // Preset as the base, the CLI flag on top, the alias itself gone. + const sent = calls[1].options as Record; + assert.strictEqual(sent.vmObfuscation, true); + assert.strictEqual(sent.optionsPreset, 'vm-default'); + assert.strictEqual(sent.compact, true); + assert.strictEqual(fs.readFileSync(outputPath, 'utf8'), 'var obfuscated=1;'); + + fs.rmSync(outputPath, { force: true }); + }); + }); + + describe('Variant #2: VM preset name from a config file', () => { + it('should send the name to the Pro API instead of failing to expand it locally', async () => { + const outputPath = path.join(outputDirName, 'preset-output2.js'); + const calls = stubEndpoints(null); + + await JavaScriptObfuscatorCLI.obfuscate([ + 'node', + 'javascript-obfuscator', + presetFilePath, + '--output', + outputPath, + '--pro-api-token', + 'test-token-123', + '--config', + configFilePath + ]); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.deepEqual( + calls.map((call) => call.url), + [OBFUSCATE_URL] + ); + const sent = calls[0].options as Record; + assert.strictEqual(sent.optionsPreset, 'vm-default'); + assert.strictEqual(sent.target, 'browser'); + + fs.rmSync(outputPath, { force: true }); + }); + }); + + describe('Variant #3: custom preset without Pro features', () => { + it('should obfuscate locally with the fetched options', async () => { + const outputPath = path.join(outputDirName, 'preset-output3.js'); + const calls = stubEndpoints({ compact: false, optionsPreset: 'default' }); + + await JavaScriptObfuscatorCLI.obfuscate([ + 'node', + 'javascript-obfuscator', + presetFilePath, + '--output', + outputPath, + '--pro-api-token', + 'test-token-123', + '--options-preset', + 'production' + ]); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.deepEqual( + calls.map((call) => call.url), + [`${PRESETS_URL}production`] + ); + // Non-compact local output: the function body spans lines. + assert.include(fs.readFileSync(outputPath, 'utf8'), '\n'); + + fs.rmSync(outputPath, { force: true }); + }); + }); + + describe('Variant #4: directory input', () => { + it('should fetch the preset once for the whole run', async () => { + const outputPath = path.join(outputDirName, 'preset-dir-output'); + const calls = stubEndpoints({ vmObfuscation: true }); + + await JavaScriptObfuscatorCLI.obfuscate([ + 'node', + 'javascript-obfuscator', + presetDirPath, + '--output', + outputPath, + '--pro-api-token', + 'test-token-123', + '--options-preset', + 'production' + ]); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const presetCalls = calls.filter((call) => call.url.startsWith(PRESETS_URL)); + const obfuscateCalls = calls.filter((call) => call.url === OBFUSCATE_URL); + + assert.lengthOf(presetCalls, 1); + assert.lengthOf(obfuscateCalls, 2); + + fs.rmSync(outputPath, { recursive: true, force: true }); + }); + }); + + describe('Variant #5: unknown custom preset', () => { + it('should fail with the 404 message', async () => { + const outputPath = path.join(outputDirName, 'preset-output5.js'); + + stubEndpoints(null); + + let error: Error | undefined; + + try { + await JavaScriptObfuscatorCLI.obfuscate([ + 'node', + 'javascript-obfuscator', + presetFilePath, + '--output', + outputPath, + '--pro-api-token', + 'test-token-123', + '--options-preset', + 'prodction' + ]); + } catch (caught) { + error = caught as Error; + } + + assert.instanceOf(error, ApiError); + assert.include(error!.message, 'prodction'); + assert.isFalse(fs.existsSync(outputPath)); + }); + }); + }); + describe('hasProFeatures static method', () => { it('should return true for vmObfuscation', () => { assert.isTrue(ProApiClient.hasProFeatures({ vmObfuscation: true })); diff --git a/test/functional-tests/pro-api/ProApiClient.spec.ts b/test/functional-tests/pro-api/ProApiClient.spec.ts index aea78dec..716a3226 100644 --- a/test/functional-tests/pro-api/ProApiClient.spec.ts +++ b/test/functional-tests/pro-api/ProApiClient.spec.ts @@ -93,6 +93,119 @@ describe('JavaScriptObfuscator.obfuscatePro', () => { }); }); + describe('custom presets', () => { + const PRESETS_URL = 'https://obfuscator.io/api/v1/presets/'; + + /** + * Two endpoints answer in these tests: the presets endpoint with the + * saved preset, the obfuscate endpoint with an NDJSON result. The + * obfuscate request body is captured so the merged options can be + * asserted. + */ + const mockPresetAndObfuscate = (presetOptions: object | null, obfuscatedCode: string) => { + const obfuscateBodies: string[] = []; + + fetchStub = sinon.stub(global, 'fetch').callsFake(async (url: unknown, init?: RequestInit) => { + if (String(url).startsWith(PRESETS_URL)) { + return { + ok: presetOptions !== null, + status: presetOptions !== null ? 200 : 404, + text: async () => + JSON.stringify( + presetOptions !== null + ? { + alias: 'production', + name: 'Production', + description: null, + options: presetOptions, + updatedAt: '2026-09-20T12:00:00.000Z' + } + : { error: 'Preset not found' } + ) + } as Response; + } + + obfuscateBodies.push(String(init?.body)); + + return { + ok: true, + status: 200, + text: async () => createNdjsonResponse([{ type: 'result', code: obfuscatedCode, sourceMap: '' }]) + } as Response; + }); + + return obfuscateBodies; + }; + + it('should fetch a custom preset and obfuscate through the Pro API with its options', async () => { + const obfuscateBodies = mockPresetAndObfuscate( + { vmObfuscation: true, optionsPreset: 'vm-default', compact: false }, + 'var _0x1234 = 1;' + ); + + // No Pro feature in the caller's options: the preset supplies it. + const result = await JavaScriptObfuscator.obfuscatePro( + 'const a = 1;', + { optionsPreset: 'production', compact: true }, + { apiToken: 'test-token' } + ); + + assert.equal(result.getObfuscatedCode(), 'var _0x1234 = 1;'); + assert.equal(obfuscateBodies.length, 1); + assert.deepEqual(JSON.parse(obfuscateBodies[0]).options, { + vmObfuscation: true, + optionsPreset: 'vm-default', + compact: true + }); + }); + + it('should obfuscate locally when the custom preset enables no Pro feature', async () => { + const obfuscateBodies = mockPresetAndObfuscate({ compact: false, optionsPreset: 'default' }, 'unused'); + + const result = await JavaScriptObfuscator.obfuscatePro( + 'function f() { const a = 1; return a; }', + { optionsPreset: 'production' }, + { apiToken: 'test-token' } + ); + + assert.equal(obfuscateBodies.length, 0); + // Local output honours the preset's `compact: false`: the function + // body is printed on its own lines. + assert.include(result.getObfuscatedCode(), '\n'); + }); + + it('should send a VM preset name to the Pro API without expanding it locally', async () => { + const obfuscateBodies = mockPresetAndObfuscate(null, 'var _0x1234 = 1;'); + + const result = await JavaScriptObfuscator.obfuscatePro( + 'const a = 1;', + { optionsPreset: 'vm-default' }, + { apiToken: 'test-token' } + ); + + assert.equal(result.getObfuscatedCode(), 'var _0x1234 = 1;'); + assert.equal(obfuscateBodies.length, 1); + assert.deepEqual(JSON.parse(obfuscateBodies[0]).options, { optionsPreset: 'vm-default' }); + }); + + it('should throw ApiError 404 for an unknown custom preset', async () => { + mockPresetAndObfuscate(null, 'unused'); + + try { + await JavaScriptObfuscator.obfuscatePro( + 'const a = 1;', + { optionsPreset: 'prodction' }, + { apiToken: 'test-token' } + ); + assert.fail('Should have thrown'); + } catch (error) { + assert.instanceOf(error, ApiError); + assert.equal((error as ApiError).statusCode, 404); + assert.include((error as ApiError).message, 'prodction'); + } + }); + }); + describe('streaming response - direct result', () => { it('should handle direct result response', async () => { const obfuscatedCode = 'var _0x1234 = function() { return 1; };'; diff --git a/test/unit-tests/pro-api/ProApiClient.spec.ts b/test/unit-tests/pro-api/ProApiClient.spec.ts index c99d68ed..a128c69b 100644 --- a/test/unit-tests/pro-api/ProApiClient.spec.ts +++ b/test/unit-tests/pro-api/ProApiClient.spec.ts @@ -76,6 +76,229 @@ describe('ProApiClient', () => { }); }); + describe('isBuiltInPreset', () => { + describe('Variant #1: OSS preset names', () => { + it('should return true for the presets the local obfuscator knows', () => { + assert.isTrue(ProApiClient.isBuiltInPreset('default')); + assert.isTrue(ProApiClient.isBuiltInPreset('low-obfuscation')); + assert.isTrue(ProApiClient.isBuiltInPreset('medium-obfuscation')); + assert.isTrue(ProApiClient.isBuiltInPreset('high-obfuscation')); + }); + }); + + describe('Variant #2: VM preset names', () => { + it('should return true for the presets only the Pro API knows', () => { + assert.isTrue(ProApiClient.isBuiltInPreset('vm-low-obfuscation')); + assert.isTrue(ProApiClient.isBuiltInPreset('vm-default')); + assert.isTrue(ProApiClient.isBuiltInPreset('vm-medium-obfuscation')); + assert.isTrue(ProApiClient.isBuiltInPreset('vm-high-obfuscation')); + assert.isTrue(ProApiClient.isBuiltInPreset('vm-ultra-high-obfuscation')); + assert.isTrue(ProApiClient.isBuiltInPreset('vm-anti-llm')); + }); + }); + + describe('Variant #3: anything else', () => { + it('should return false for a custom alias', () => { + assert.isFalse(ProApiClient.isBuiltInPreset('production')); + }); + + it('should return false when no preset is set', () => { + assert.isFalse(ProApiClient.isBuiltInPreset(undefined)); + }); + }); + }); + + describe('fetchPreset', () => { + const PRESETS_URL = 'https://obfuscator.io/api/v1/presets'; + + const preset = { + alias: 'production', + name: 'Production', + description: null, + options: { vmObfuscation: true, optionsPreset: 'vm-default', compact: false }, + updatedAt: '2026-09-20T12:00:00.000Z' + }; + + const jsonResponse = (body: object, status: number): Response => + ({ + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(body) + }) as Response; + + describe('Variant #1: request shape', () => { + it('should GET the alias with the Bearer token', async () => { + const client = new ProApiClient({ apiToken: 'test-token' }); + + fetchStub.resolves(jsonResponse(preset, 200)); + + await client.fetchPreset('production'); + + assert.isTrue(fetchStub.calledOnce); + assert.strictEqual(fetchStub.firstCall.args[0], `${PRESETS_URL}/production`); + assert.strictEqual(fetchStub.firstCall.args[1].method, 'GET'); + assert.strictEqual(fetchStub.firstCall.args[1].headers['Authorization'], 'Bearer test-token'); + }); + + it('should encode the alias in the URL', async () => { + const client = new ProApiClient({ apiToken: 'test-token' }); + + fetchStub.resolves(jsonResponse(preset, 200)); + + await client.fetchPreset('prod build'); + + assert.strictEqual(fetchStub.firstCall.args[0], `${PRESETS_URL}/prod%20build`); + }); + }); + + describe('Variant #2: found', () => { + it('should return the preset', async () => { + const client = new ProApiClient({ apiToken: 'test-token' }); + + fetchStub.resolves(jsonResponse(preset, 200)); + + const result = await client.fetchPreset('production'); + + assert.deepEqual(result, preset); + }); + }); + + describe('Variant #3: not found', () => { + it('should return null on 404', async () => { + const client = new ProApiClient({ apiToken: 'test-token' }); + + fetchStub.resolves(jsonResponse({ error: 'Preset not found' }, 404)); + + const result = await client.fetchPreset('production'); + + assert.isNull(result); + }); + }); + + describe('Variant #4: other errors', () => { + it('should throw ApiError carrying the API message and status', async () => { + const client = new ProApiClient({ apiToken: 'test-token' }); + + fetchStub.resolves(jsonResponse({ error: 'Invalid or expired API key' }, 401)); + + let error: ApiError | undefined; + + try { + await client.fetchPreset('production'); + } catch (caught) { + error = caught as ApiError; + } + + assert.instanceOf(error, ApiError); + assert.strictEqual(error!.message, 'Invalid or expired API key'); + assert.strictEqual(error!.statusCode, 401); + }); + }); + + describe('Variant #5: memoisation', () => { + it('should request each alias once per client', async () => { + const client = new ProApiClient({ apiToken: 'test-token' }); + + fetchStub.resolves(jsonResponse(preset, 200)); + + await client.fetchPreset('production'); + await client.fetchPreset('production'); + + assert.isTrue(fetchStub.calledOnce); + }); + }); + }); + + describe('resolveOptions', () => { + const preset = { + alias: 'production', + name: 'Production', + description: null, + options: { vmObfuscation: true, optionsPreset: 'vm-default', compact: false, selfDefending: true }, + updatedAt: '2026-09-20T12:00:00.000Z' + }; + + const jsonResponse = (body: object, status: number): Response => + ({ + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(body) + }) as Response; + + describe('Variant #1: no preset', () => { + it('should return the options untouched without a request', async () => { + const client = new ProApiClient({ apiToken: 'test-token' }); + const options = { vmObfuscation: true, compact: true }; + + const result = await client.resolveOptions(options); + + assert.deepEqual(result, options); + assert.isFalse(fetchStub.called); + }); + }); + + describe('Variant #2: built-in preset', () => { + it('should return the options untouched without a request', async () => { + const client = new ProApiClient({ apiToken: 'test-token' }); + const options = { optionsPreset: 'vm-default', vmObfuscation: true }; + + const result = await client.resolveOptions(options); + + assert.deepEqual(result, options); + assert.isFalse(fetchStub.called); + }); + }); + + describe('Variant #3: custom preset', () => { + it('should use the preset as the base and the caller options as overrides', async () => { + const client = new ProApiClient({ apiToken: 'test-token' }); + + fetchStub.resolves(jsonResponse(preset, 200)); + + const result = await client.resolveOptions({ optionsPreset: 'production', compact: true }); + + assert.deepEqual(result, { + // From the preset: its own built-in preset and VM flag survive. + vmObfuscation: true, + optionsPreset: 'vm-default', + selfDefending: true, + // From the caller: overrides the preset's `compact: false`. + compact: true + }); + }); + + it('should drop the alias when the preset carries no optionsPreset of its own', async () => { + const client = new ProApiClient({ apiToken: 'test-token' }); + + fetchStub.resolves(jsonResponse({ ...preset, options: { vmObfuscation: true } }, 200)); + + const result = await client.resolveOptions({ optionsPreset: 'production' }); + + assert.deepEqual(result, { vmObfuscation: true }); + }); + }); + + describe('Variant #4: unknown custom preset', () => { + it('should throw ApiError 404 naming the alias', async () => { + const client = new ProApiClient({ apiToken: 'test-token' }); + + fetchStub.resolves(jsonResponse({ error: 'Preset not found' }, 404)); + + let error: ApiError | undefined; + + try { + await client.resolveOptions({ optionsPreset: 'prodction' }); + } catch (caught) { + error = caught as ApiError; + } + + assert.instanceOf(error, ApiError); + assert.strictEqual(error!.statusCode, 404); + assert.include(error!.message, 'prodction'); + }); + }); + }); + describe('constructor', () => { describe('Variant #1: basic configuration', () => { it('should create client with required apiToken', () => { diff --git a/test/unit-tests/storages/identifier-names-cache/GlobalIdentifierNamesCacheStorage.spec.ts b/test/unit-tests/storages/identifier-names-cache/GlobalIdentifierNamesCacheStorage.spec.ts index 7eedcedd..acd710c5 100644 --- a/test/unit-tests/storages/identifier-names-cache/GlobalIdentifierNamesCacheStorage.spec.ts +++ b/test/unit-tests/storages/identifier-names-cache/GlobalIdentifierNamesCacheStorage.spec.ts @@ -5,6 +5,7 @@ import { assert } from 'chai'; import { ServiceIdentifiers } from '../../../../src/container/ServiceIdentifiers'; import { TDictionary } from '../../../../src/types/TDictionary'; +import { TInputOptions } from '../../../../src/types/options/TInputOptions'; import { IGlobalIdentifierNamesCacheStorage } from '../../../../src/interfaces/storages/identifier-names-cache/IGlobalIdentifierNamesCacheStorage'; import { IInversifyContainerFacade } from '../../../../src/interfaces/container/IInversifyContainerFacade'; @@ -19,7 +20,7 @@ import { InversifyContainerFacade } from '../../../../src/container/InversifyCon /** * @returns {IGlobalIdentifierNamesCacheStorage} */ -const getStorageInstance = (options: Partial = DEFAULT_PRESET): IGlobalIdentifierNamesCacheStorage => { +const getStorageInstance = (options: TInputOptions = DEFAULT_PRESET): IGlobalIdentifierNamesCacheStorage => { const inversifyContainerFacade: IInversifyContainerFacade = new InversifyContainerFacade(); inversifyContainerFacade.load('', '', {}); diff --git a/test/unit-tests/storages/identifier-names-cache/PropertyIdentifierNamesCacheStorage.spec.ts b/test/unit-tests/storages/identifier-names-cache/PropertyIdentifierNamesCacheStorage.spec.ts index 9b8e0d48..8b764630 100644 --- a/test/unit-tests/storages/identifier-names-cache/PropertyIdentifierNamesCacheStorage.spec.ts +++ b/test/unit-tests/storages/identifier-names-cache/PropertyIdentifierNamesCacheStorage.spec.ts @@ -5,6 +5,7 @@ import { assert } from 'chai'; import { ServiceIdentifiers } from '../../../../src/container/ServiceIdentifiers'; import { TDictionary } from '../../../../src/types/TDictionary'; +import { TInputOptions } from '../../../../src/types/options/TInputOptions'; import { IPropertyIdentifierNamesCacheStorage } from '../../../../src/interfaces/storages/identifier-names-cache/IPropertyIdentifierNamesCacheStorage'; import { IInversifyContainerFacade } from '../../../../src/interfaces/container/IInversifyContainerFacade'; @@ -19,7 +20,7 @@ import { PropertyIdentifierNamesCacheStorage } from '../../../../src/storages/id /** * @returns {IPropertyIdentifierNamesCacheStorage} */ -const getStorageInstance = (options: Partial = DEFAULT_PRESET): IPropertyIdentifierNamesCacheStorage => { +const getStorageInstance = (options: TInputOptions = DEFAULT_PRESET): IPropertyIdentifierNamesCacheStorage => { const inversifyContainerFacade: IInversifyContainerFacade = new InversifyContainerFacade(); inversifyContainerFacade.load('', '', {});