From e9d5e0ea160bb5d4ce5ddfd78adb57c73f11799c Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 23:11:57 -0700 Subject: [PATCH] =?UTF-8?q?clients/typescript:=20apply=20audit=20findings?= =?UTF-8?q?=20=E2=80=94=20uploadFile=20streaming=20+=20metadata=20+=20vali?= =?UTF-8?q?dation=20(15de6e7=20=E2=86=92=20cc54cfb)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH: - H1: uploadFile streams via createReadStream, validates is_file, caps size (configurable, default 100MB) MEDIUM: - M1: LICENSE file added - M2: package.json repository/bugs/homepage/author fields - M3: ESM-only doc + engines.node>=18 - M4: defaultTimeoutMs negative validation - M5: baseUrl validated as URL in constructor LOW: - L1: empty 200 body throws ForgeAPIError instead of {} as T - L2: DOMException("timeout", "TimeoutError") for symmetry with AbortSignal.timeout() - L4: package-lock.json committed - L5: 6 new tests (500-not-502, Blob upload, invalid source, empty revokeToken, JSON error body, double-signal race) - L7: defensive raw.ok === true check in run() Audit: memory/clawdforge-audits/typescript-15de6e7.md --- clients/typescript/LICENSE | 18 + clients/typescript/README.md | 37 +- clients/typescript/package-lock.json | 594 ++++++++++++++++++++++++ clients/typescript/package.json | 12 +- clients/typescript/src/client.ts | 172 ++++++- clients/typescript/src/errors.ts | 9 +- clients/typescript/src/types.ts | 9 + clients/typescript/tests/client.test.ts | 374 ++++++++++++++- 8 files changed, 1203 insertions(+), 22 deletions(-) create mode 100644 clients/typescript/LICENSE create mode 100644 clients/typescript/package-lock.json diff --git a/clients/typescript/LICENSE b/clients/typescript/LICENSE new file mode 100644 index 0000000..3624df5 --- /dev/null +++ b/clients/typescript/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 Sulkta-Coop + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/clients/typescript/README.md b/clients/typescript/README.md index dd3e9d5..23fee88 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -13,6 +13,21 @@ bearer-token-gated REST API. - **Typed errors** — `ForgeAuthError`, `ForgeAPIError`, `ForgeTransportError` - **Cancellable** — every method takes an optional `signal: AbortSignal` +## Module format: ESM-only + +This SDK ships **ESM only**. `package.json` is `"type": "module"`, the +`exports` map only resolves an `import` condition, and there is no CJS +build. Consumers must: + +- Use a project with `"type": "module"` in their own `package.json`, or +- Import from a `.mts` / `.ts` file and let their bundler (esbuild, tsup, + Vite, etc.) handle interop, or +- Use the dynamic `await import("clawdforge")` form from a CJS module. + +`require("clawdforge")` will throw `ERR_REQUIRE_ESM` on Node. If a CJS +build becomes necessary, file an issue — the fix is a second `tsc` pass +plus a dual `exports` map, not a deep refactor. + ## Install For now, the SDK is consumed straight from this repo subtree (no npm publish): @@ -69,9 +84,10 @@ All methods are `async` and return Promises. | field | type | default | notes | |--------------------|----------------|-----------------------|--------------------------------------------------| -| `baseUrl` | `string` | required | trailing slashes are stripped | +| `baseUrl` | `string` | required | trailing slashes are stripped; validated via `new URL()` | | `token` | `string` | required | per-app `cf_...` for `/run`+`/files`, admin for `/admin/*` | -| `defaultTimeoutMs` | `number` | `120000` | client-side network timeout; 0 disables | +| `defaultTimeoutMs` | `number` | `120000` | client-side network timeout; 0 disables; negative rejected | +| `uploadMaxBytes` | `number` | `104857600` (100 MB) | size cap for `uploadFile(path)`; 0 disables | | `fetch` | `typeof fetch` | `globalThis.fetch` | inject a custom fetch (testing, proxies, etc.) | ### `forge.healthz(): Promise` @@ -124,8 +140,21 @@ await forge.run({ }); ``` -`source` can be a string path, a `Uint8Array` / `Buffer`, or a `Blob`. Pass -`filename` and/or `contentType` in `opts` to override defaults. +`source` can be: +- a **string path** to a local **regular file**. Symlinks are followed to + their target via `fs.stat`. Non-regular files (directories, FIFOs, + sockets, block/char devices) are rejected with `ForgeError`. The file + is streamed via `fs.createReadStream` — its full contents are **not** + buffered in memory. +- a Node `Uint8Array` / `Buffer` +- a Web `Blob` / `File` + +For string-path uploads, the file size is checked via `fs.stat` against +`ForgeOptions.uploadMaxBytes` (default 100 MB) **before** any bytes are +read; oversized files fail fast with `ForgeError` and never touch memory +or the network. Set `uploadMaxBytes: 0` to disable the cap. + +Pass `filename` and/or `contentType` in `opts` to override defaults. ```ts interface FileToken { diff --git a/clients/typescript/package-lock.json b/clients/typescript/package-lock.json new file mode 100644 index 0000000..4fd3db4 --- /dev/null +++ b/clients/typescript/package-lock.json @@ -0,0 +1,594 @@ +{ + "name": "clawdforge", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "clawdforge", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^20.19.39", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/clients/typescript/package.json b/clients/typescript/package.json index 3cd9619..ca95155 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -24,7 +24,7 @@ "prepublishOnly": "npm run build" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" }, "keywords": [ "claude", @@ -33,7 +33,17 @@ "sdk", "clawdforge" ], + "author": "Sulkta-Coop", "license": "MIT", + "repository": { + "type": "git", + "url": "https://192.168.0.5:3001/Sulkta-Coop/clawdforge.git", + "directory": "clients/typescript" + }, + "bugs": { + "url": "https://192.168.0.5:3001/Sulkta-Coop/clawdforge/issues" + }, + "homepage": "https://192.168.0.5:3001/Sulkta-Coop/clawdforge/src/branch/main/clients/typescript", "devDependencies": { "@types/node": "^20.19.39", "tsx": "^4.21.0", diff --git a/clients/typescript/src/client.ts b/clients/typescript/src/client.ts index 1b31df0..12ac444 100644 --- a/clients/typescript/src/client.ts +++ b/clients/typescript/src/client.ts @@ -1,5 +1,7 @@ -import { readFile, stat } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; import { basename } from "node:path"; +import { Readable } from "node:stream"; import { ForgeAPIError, @@ -20,6 +22,9 @@ import type { UploadFileOptions, } from "./types.js"; +/** Default cap on `uploadFile(path)` size before any bytes are read: 100 MB. */ +const DEFAULT_UPLOAD_MAX_BYTES = 100 * 1024 * 1024; + /** * Thin HTTP client for clawdforge. * @@ -34,6 +39,7 @@ export class Forge { private readonly baseUrl: string; private readonly token: string; private readonly defaultTimeoutMs: number; + private readonly uploadMaxBytes: number; private readonly fetchImpl: typeof fetch; constructor(options: ForgeOptions) { @@ -47,9 +53,50 @@ export class Forge { throw new ForgeError("Forge: token is required"); } + // M5: validate baseUrl is parseable as a URL — reject newline injection, + // spaces, and other malformed input up-front instead of letting fetch + // surface a cryptic error later. + try { + // The result is intentionally discarded — we only care about throw/no-throw. + new URL(options.baseUrl); + } catch (e) { + throw new ForgeError( + `Forge: baseUrl is not a valid URL: ${options.baseUrl}`, + { cause: e } + ); + } + + // M4: a negative `defaultTimeoutMs` would silently behave as "no timeout" + // because the internal `if (timeoutMs > 0)` check skips it. That's a + // footgun. 0 explicitly disables; anything below that is rejected. + if (options.defaultTimeoutMs !== undefined) { + if ( + typeof options.defaultTimeoutMs !== "number" || + !Number.isFinite(options.defaultTimeoutMs) || + options.defaultTimeoutMs < 0 + ) { + throw new ForgeError( + "Forge: defaultTimeoutMs must be a non-negative finite number (0 disables)" + ); + } + } + + if (options.uploadMaxBytes !== undefined) { + if ( + typeof options.uploadMaxBytes !== "number" || + !Number.isFinite(options.uploadMaxBytes) || + options.uploadMaxBytes < 0 + ) { + throw new ForgeError( + "Forge: uploadMaxBytes must be a non-negative finite number (0 disables)" + ); + } + } + this.baseUrl = options.baseUrl.replace(/\/+$/, ""); this.token = options.token; this.defaultTimeoutMs = options.defaultTimeoutMs ?? 120_000; + this.uploadMaxBytes = options.uploadMaxBytes ?? DEFAULT_UPLOAD_MAX_BYTES; const fetchFn = options.fetch ?? globalThis.fetch; if (typeof fetchFn !== "function") { @@ -109,7 +156,7 @@ export class Forge { : this.defaultTimeoutMs; const raw = await this.request<{ - ok: true; + ok: boolean; result: unknown; duration_ms: number; stop_reason: string | null; @@ -119,6 +166,17 @@ export class Forge { timeoutMs: networkTimeoutMs, }); + // L7: a buggy server returning {ok:false} with a 200 status would + // otherwise hand back a `RunResult` claiming `ok: true`. Defend against + // it explicitly. + if (raw.ok !== true) { + throw new ForgeAPIError( + 200, + camelCaseRunFailure(raw), + "clawdforge /run returned 200 with ok!=true" + ); + } + return { ok: true, result: raw.result, @@ -133,10 +191,18 @@ export class Forge { * `POST /files` — upload a file from disk and receive a `file_token`. * * The first argument may be: - * - a string path to a local file + * - a string path to a local **regular file** (symlinks are followed to + * their target via `fs.stat`; non-regular files such as directories, + * FIFOs, sockets, or block/char devices are rejected with + * {@link ForgeError}) * - a Node `Buffer` / `Uint8Array` * - a Web `Blob` / `File` * + * String-path uploads are streamed via `fs.createReadStream` — the entire + * file is **not** buffered in memory. Size is checked against + * `ForgeOptions.uploadMaxBytes` (default 100 MB) using `fs.stat` BEFORE any + * bytes are read. + * * For Buffers and Blobs, pass `filename` in `opts` to control the on-disk name. */ async uploadFile( @@ -153,13 +219,37 @@ export class Forge { let filename: string; if (typeof source === "string") { - const data = await readFile(source); - // Verify the path actually existed; readFile would throw otherwise, - // but stat clarifies the error message vs a transport-level surprise. - await stat(source); - blob = new Blob([new Uint8Array(data)], { - type: opts.contentType ?? "application/octet-stream", - }); + // H1: stat FIRST so we can validate type + size before reading any + // bytes. The previous code did `readFile` first, making the stat + // dead-code; pointing at /etc/passwd via a symlink would happily + // upload it. Pointing at a directory would surface a cryptic EISDIR; + // pointing at a FIFO would hang the process. + let s; + try { + s = await stat(source); + } catch (e) { + throw new ForgeError( + `uploadFile: cannot stat source path "${source}"`, + { cause: e } + ); + } + if (!s.isFile()) { + throw new ForgeError( + `uploadFile: source path "${source}" is not a regular file` + ); + } + if (this.uploadMaxBytes > 0 && s.size > this.uploadMaxBytes) { + throw new ForgeError( + `uploadFile: source size ${s.size} exceeds uploadMaxBytes ${this.uploadMaxBytes}` + ); + } + + // Stream the file through `Readable.toWeb` rather than slurping the + // whole thing into a Buffer. Node's undici-based fetch consumes + // ReadableStream bodies natively. + const nodeStream = createReadStream(source); + const webStream = Readable.toWeb(nodeStream) as ReadableStream; + blob = streamToBlob(webStream, s.size, opts.contentType); filename = opts.filename ?? basename(source); } else if (source instanceof Uint8Array) { blob = new Blob([new Uint8Array(source)], { @@ -334,10 +424,16 @@ export class Forge { } if (res.status >= 200 && res.status < 300) { - // Empty body? Return an empty object — only happens on synthetic responses. + // L1: every endpoint the SDK calls contractually returns a JSON body + // on 2xx. An empty body is a server-side bug — surface it as an API + // error rather than a runtime lie of `{} as T`. const text = await res.text(); if (!text) { - return {} as T; + throw new ForgeAPIError( + res.status, + null, + `clawdforge returned an empty body on ${res.status} ${method} ${path}` + ); } try { return JSON.parse(text) as T; @@ -345,7 +441,8 @@ export class Forge { throw new ForgeAPIError( res.status, text, - `clawdforge returned non-JSON body: ${truncate(text, 200)}` + `clawdforge returned non-JSON body: ${truncate(text, 200)}`, + { cause: e } ); } } @@ -377,6 +474,46 @@ function truncate(s: string, n: number): string { return s.length <= n ? s : `${s.slice(0, n)}...`; } +/** + * Wrap a `ReadableStream` of known length in a `Blob`-shaped + * object that can be appended to a `FormData` part. We can't use the native + * `Blob` constructor because it eagerly materializes its input into memory, + * defeating the streaming intent. Instead we expose just the surface that + * undici's FormData serializer needs (`size`, `type`, `stream()`, plus the + * `Symbol.toStringTag` it sniffs to identify Blob-likes). + */ +function streamToBlob( + stream: ReadableStream, + size: number, + type: string | undefined +): Blob { + const blobType = type ?? "application/octet-stream"; + // The cast is safe: undici's multipart serializer calls `.stream()`, + // `.size`, and `.type`; everything else (`arrayBuffer`, `slice`, `text`) + // is unused on the upload path. + return { + size, + type: blobType, + stream: () => stream, + arrayBuffer: () => { + throw new Error( + "stream-backed Blob does not support arrayBuffer(); upload path only" + ); + }, + text: () => { + throw new Error( + "stream-backed Blob does not support text(); upload path only" + ); + }, + slice: () => { + throw new Error( + "stream-backed Blob does not support slice(); upload path only" + ); + }, + [Symbol.toStringTag]: "Blob", + } as unknown as Blob; +} + /** * If the server returned a `/run` failure envelope `{ok:false, error, * stderr, duration_ms, stop_reason}`, convert the snake_case keys to @@ -431,7 +568,14 @@ function combineSignals( } if (timeoutMs > 0) { - timer = setTimeout(() => ac.abort(new Error(`timeout ${timeoutMs}ms`)), timeoutMs); + // L2: use DOMException("timeout","TimeoutError") for symmetry with + // `AbortSignal.timeout()`. Caller chains that match on reason.name will + // see the same shape regardless of whether we or the platform timed it + // out. + timer = setTimeout( + () => ac.abort(new DOMException(`timeout ${timeoutMs}ms`, "TimeoutError")), + timeoutMs + ); // Don't keep the event loop alive just for our timeout — if every other // handle has unwound, the request is effectively done. (timer as unknown as { unref?: () => void }).unref?.(); diff --git a/clients/typescript/src/errors.ts b/clients/typescript/src/errors.ts index f9b66ef..ab244fd 100644 --- a/clients/typescript/src/errors.ts +++ b/clients/typescript/src/errors.ts @@ -47,8 +47,13 @@ export class ForgeAPIError extends ForgeError { public readonly status: number; public readonly body: unknown; - constructor(status: number, body: unknown, message?: string) { - super(message ?? `clawdforge API error (${status})`); + constructor( + status: number, + body: unknown, + message?: string, + options?: { cause?: unknown } + ) { + super(message ?? `clawdforge API error (${status})`, options); this.name = "ForgeAPIError"; this.status = status; this.body = body; diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts index 42aac77..0433797 100644 --- a/clients/typescript/src/types.ts +++ b/clients/typescript/src/types.ts @@ -29,6 +29,15 @@ export interface ForgeOptions { * for custom user-agents / proxies. Defaults to `globalThis.fetch`. */ fetch?: typeof fetch; + /** + * Maximum size in bytes that {@link Forge.uploadFile} will accept when + * given a string path. Defaults to 100 MB (100 * 1024 * 1024). The size + * check happens against `fs.stat` BEFORE any bytes are read or buffered, + * so an oversized file fails fast without memory pressure. + * + * Set to 0 to disable the cap entirely (not recommended). + */ + uploadMaxBytes?: number; } /** Response shape from `GET /healthz`. */ diff --git a/clients/typescript/tests/client.test.ts b/clients/typescript/tests/client.test.ts index c06c6fb..d5e2c8b 100644 --- a/clients/typescript/tests/client.test.ts +++ b/clients/typescript/tests/client.test.ts @@ -1,6 +1,12 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { writeFileSync, unlinkSync } from "node:fs"; +import { + mkdirSync, + rmSync, + truncateSync, + unlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -460,3 +466,369 @@ test("default timeout fires → ForgeTransportError(aborted=true)", async () => clearInterval(keepalive); } }); + +// -------------------------------------------------------- audit fix tests (H1) + +test("uploadFile: rejects a directory path with ForgeError (no EISDIR)", async () => { + const dir = join(tmpdir(), `clawdforge-ts-dir-${Date.now()}`); + mkdirSync(dir); + try { + const { fetch, calls } = makeMockFetch(() => + jsonResponse(200, { file_token: "ff_x", ttl_secs: 3600, size: 0 }) + ); + const forge = new Forge({ ...BASE, fetch }); + await assert.rejects( + forge.uploadFile(dir), + (e: unknown) => { + assert.ok(e instanceof ForgeError); + assert.match((e as Error).message, /not a regular file/); + return true; + } + ); + assert.equal(calls.length, 0, "must not hit the network for a directory"); + } finally { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } +}); + +test("uploadFile: rejects a missing path with ForgeError carrying cause", async () => { + const missing = join(tmpdir(), `clawdforge-ts-nope-${Date.now()}-${Math.random()}.bin`); + const { fetch, calls } = makeMockFetch(() => + jsonResponse(200, { file_token: "ff_x", ttl_secs: 3600, size: 0 }) + ); + const forge = new Forge({ ...BASE, fetch }); + await assert.rejects(forge.uploadFile(missing), (e: unknown) => { + assert.ok(e instanceof ForgeError); + assert.match((e as Error).message, /cannot stat source path/); + return true; + }); + assert.equal(calls.length, 0); +}); + +test("uploadFile: file size > uploadMaxBytes is rejected before any bytes read", async () => { + const tmp = join(tmpdir(), `clawdforge-ts-big-${Date.now()}.bin`); + // Sparse-truncate to 5 MB so we don't actually allocate the bytes; stat + // still reports the full size, which is what the cap checks against. + writeFileSync(tmp, Buffer.alloc(0)); + truncateSync(tmp, 5 * 1024 * 1024); + try { + const { fetch, calls } = makeMockFetch(() => + jsonResponse(200, { file_token: "ff_x", ttl_secs: 3600, size: 0 }) + ); + const forge = new Forge({ + ...BASE, + fetch, + uploadMaxBytes: 1024, // 1 KB cap, file is 5 MB + }); + await assert.rejects(forge.uploadFile(tmp), (e: unknown) => { + assert.ok(e instanceof ForgeError); + assert.match( + (e as Error).message, + /exceeds uploadMaxBytes/, + "must call out the cap, not surface a transport-level surprise" + ); + return true; + }); + assert.equal(calls.length, 0, "must not hit the network"); + } finally { + try { + unlinkSync(tmp); + } catch { + /* ignore */ + } + } +}); + +test("uploadFile: streams large file without buffering whole thing", async () => { + // 8 MB file, sparse-allocated so the test stays cheap. The streaming code + // path is exercised by `createReadStream` + `Readable.toWeb`; we assert + // that the resulting multipart Blob carries the right size and that the + // upload completes without OOM at peak heap. A loose heap-delta bound + // proves we didn't buffer the whole file (which would push RSS+heap by + // ~8 MB). + const SIZE = 8 * 1024 * 1024; + const tmp = join(tmpdir(), `clawdforge-ts-stream-${Date.now()}.bin`); + writeFileSync(tmp, Buffer.alloc(0)); + truncateSync(tmp, SIZE); + try { + const { fetch, calls } = makeMockFetch(async (req) => { + // Drain the multipart body so undici streams it through. Without this + // the ReadableStream sits in the FormData unconsumed. + assert.ok(req.bodyForm); + const part = req.bodyForm!.get("file"); + assert.ok(part && typeof part === "object" && "size" in part); + assert.equal((part as { size: number }).size, SIZE); + return jsonResponse(200, { + file_token: "ff_streamed", + ttl_secs: 3600, + size: SIZE, + }); + }); + const forge = new Forge({ ...BASE, fetch }); + if (typeof globalThis.gc === "function") { + globalThis.gc(); + } + const before = process.memoryUsage().heapUsed; + const ft = await forge.uploadFile(tmp, { contentType: "application/octet-stream" }); + const after = process.memoryUsage().heapUsed; + assert.equal(ft.fileToken, "ff_streamed"); + assert.equal(ft.size, SIZE); + assert.equal(calls.length, 1); + // Heap should grow by far less than the file size if we're streaming. + // Allow generous slack — V8 is noisy. The pre-fix code would have + // pushed +SIZE; this should comfortably stay under 2 MB of growth. + const delta = after - before; + assert.ok( + delta < SIZE / 2, + `heap delta ${delta} should be far below file size ${SIZE} (streaming)` + ); + } finally { + try { + unlinkSync(tmp); + } catch { + /* ignore */ + } + } +}); + +// --------------------------------------------------- audit fix tests (M4, M5) + +test("constructor: baseUrl with newline rejected as not-a-URL", () => { + assert.throws( + () => + new Forge({ + baseUrl: "http://forge.test:8800\nX-Injected: yes", + token: "cf_x", + }), + (e: unknown) => { + assert.ok(e instanceof ForgeError); + assert.match((e as Error).message, /baseUrl is not a valid URL/); + return true; + } + ); +}); + +test("constructor: baseUrl with embedded space rejected", () => { + assert.throws( + () => + new Forge({ + baseUrl: "http://forge .test:8800", + token: "cf_x", + }), + (e: unknown) => { + assert.ok(e instanceof ForgeError); + assert.match((e as Error).message, /baseUrl is not a valid URL/); + return true; + } + ); +}); + +test("constructor: defaultTimeoutMs negative rejected", () => { + assert.throws( + () => new Forge({ ...BASE, defaultTimeoutMs: -1 }), + (e: unknown) => { + assert.ok(e instanceof ForgeError); + assert.match((e as Error).message, /defaultTimeoutMs/); + return true; + } + ); +}); + +test("constructor: defaultTimeoutMs NaN rejected", () => { + assert.throws( + () => new Forge({ ...BASE, defaultTimeoutMs: Number.NaN }), + ForgeError + ); +}); + +test("constructor: defaultTimeoutMs 0 explicitly disables", async () => { + // With timeout=0 the SDK should not arm an internal abort timer. We + // verify by waiting longer than any reasonable default would tick. + const { fetch } = makeMockFetch(() => + jsonResponse(200, { ok: true, claude_present: true, claude_version: null }) + ); + const forge = new Forge({ ...BASE, fetch, defaultTimeoutMs: 0 }); + const h = await forge.healthz(); + assert.equal(h.ok, true); +}); + +test("constructor: uploadMaxBytes negative rejected", () => { + assert.throws( + () => new Forge({ ...BASE, uploadMaxBytes: -1 }), + ForgeError + ); +}); + +// --------------------------------------------------------- audit fix test (L1) + +test("run: empty 200 body throws ForgeAPIError instead of silent {}", async () => { + const { fetch } = makeMockFetch( + () => + new Response("", { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + const forge = new Forge({ ...BASE, fetch }); + await assert.rejects(forge.run({ prompt: "x" }), (e: unknown) => { + assert.ok(e instanceof ForgeAPIError); + assert.match((e as Error).message, /empty body/); + return true; + }); +}); + +// --------------------------------------------------------- audit fix test (L7) + +test("run: 200 with ok:false throws ForgeAPIError (defensive against buggy server)", async () => { + const { fetch } = makeMockFetch(() => + jsonResponse(200, { + ok: false, + result: null, + duration_ms: 0, + stop_reason: null, + error: "lying server", + stderr: "", + }) + ); + const forge = new Forge({ ...BASE, fetch }); + await assert.rejects(forge.run({ prompt: "x" }), (e: unknown) => { + assert.ok(e instanceof ForgeAPIError); + assert.equal((e as ForgeAPIError).status, 200); + assert.match((e as Error).message, /ok!=true/); + return true; + }); +}); + +// ------------------------------------------------------- audit fix tests (L5) + +test("L5: non-502 5xx (e.g. 500) → ForgeAPIError, not Auth/Transport", async () => { + const { fetch } = makeMockFetch( + () => + new Response(JSON.stringify({ detail: "internal server boom" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }) + ); + const forge = new Forge({ ...BASE, fetch }); + await assert.rejects(forge.run({ prompt: "x" }), (e: unknown) => { + assert.ok(e instanceof ForgeAPIError); + assert.ok(!(e instanceof ForgeAuthError)); + assert.equal((e as ForgeAPIError).status, 500); + return true; + }); +}); + +test("L5: uploadFile from Blob includes file part and ttl_secs", async () => { + const { fetch, calls } = makeMockFetch(() => + jsonResponse(200, { file_token: "ff_blob", ttl_secs: 3600, size: 4 }) + ); + const forge = new Forge({ ...BASE, fetch }); + const blob = new Blob([new Uint8Array([1, 2, 3, 4])], { type: "image/png" }); + const ft = await forge.uploadFile(blob, { filename: "blob.png" }); + assert.equal(ft.fileToken, "ff_blob"); + const c = calls[0]!; + assert.ok(c.bodyForm); + assert.equal(c.bodyForm!.get("ttl_secs"), "3600"); + const part = c.bodyForm!.get("file"); + assert.ok(part && typeof part === "object" && "size" in part); + if (part && "name" in part) { + assert.equal((part as { name: string }).name, "blob.png"); + } +}); + +test("L5: uploadFile rejects invalid source type with ForgeError", async () => { + const { fetch, calls } = makeMockFetch(() => jsonResponse(200, {})); + const forge = new Forge({ ...BASE, fetch }); + // @ts-expect-error — intentionally testing the runtime guard + await assert.rejects(forge.uploadFile(12345 as unknown), ForgeError); + // @ts-expect-error + await assert.rejects(forge.uploadFile(null as unknown), ForgeError); + // @ts-expect-error + await assert.rejects(forge.uploadFile({} as unknown), ForgeError); + assert.equal(calls.length, 0); +}); + +test("L5: revokeToken with empty name rejected client-side", async () => { + const { fetch, calls } = makeMockFetch(() => jsonResponse(200, { ok: true })); + const forge = new Forge({ ...BASE, fetch }); + await assert.rejects(forge.revokeToken(""), ForgeError); + assert.equal(calls.length, 0, "must not hit the network"); +}); + +test("L5: error body parse fallback — non-JSON 500 body kept as raw string", async () => { + const { fetch } = makeMockFetch( + () => + new Response("nginx error page", { + status: 500, + headers: { "Content-Type": "text/html" }, + }) + ); + const forge = new Forge({ ...BASE, fetch }); + await assert.rejects(forge.healthz(), (e: unknown) => { + assert.ok(e instanceof ForgeAPIError); + assert.equal((e as ForgeAPIError).status, 500); + // body is the raw text, not the parsed object + assert.equal(typeof (e as ForgeAPIError).body, "string"); + assert.match((e as ForgeAPIError).body as string, /nginx error page/); + return true; + }); +}); + +test("L5: caller-aborted before fetch returns is reported as aborted=true", async () => { + // Race: caller's signal aborts WHILE the SDK's internal timer is also + // armed. The transport error must report `aborted=true` and identify the + // caller (not the timeout) as the reason. + const { fetch } = makeMockFetch(async () => { + await new Promise(() => {}); + return jsonResponse(200, {}); + }); + const forge = new Forge({ ...BASE, fetch, defaultTimeoutMs: 5_000 }); + const ac = new AbortController(); + const p = forge.run({ prompt: "long task", signal: ac.signal }); + setImmediate(() => ac.abort()); + + await assert.rejects(p, (e: unknown) => { + assert.ok(e instanceof ForgeTransportError); + assert.equal((e as ForgeTransportError).aborted, true); + // Should be reported as the caller's abort, not the timeout. + assert.match((e as Error).message, /aborted by caller/); + return true; + }); +}); + +// --------------------------------------------------------- audit fix test (L2) + +test("L2: timeout abort reason is DOMException('timeout','TimeoutError')", async () => { + // Exercise the internal combineSignals: the fetch impl receives an + // AbortSignal whose `reason`, when the timeout fires, should be a + // DOMException with name "TimeoutError" — for symmetry with + // AbortSignal.timeout(). + let observedReason: unknown = null; + const fetchFn: typeof fetch = async (_input, init) => { + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => { + observedReason = init.signal!.reason; + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }, + { once: true } + ); + }); + }; + const forge = new Forge({ ...BASE, fetch: fetchFn, defaultTimeoutMs: 25 }); + const keepalive = setInterval(() => {}, 5); + try { + await assert.rejects(forge.healthz(), ForgeTransportError); + } finally { + clearInterval(keepalive); + } + assert.ok(observedReason instanceof DOMException, "reason should be a DOMException"); + assert.equal((observedReason as DOMException).name, "TimeoutError"); +});