diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b35fe33..cc341d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: timeout-minutes: 10 name: lint runs-on: ${{ github.repository == 'stainless-sdks/kernel-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 @@ -35,6 +36,7 @@ jobs: timeout-minutes: 5 name: build runs-on: ${{ github.repository == 'stainless-sdks/kernel-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork permissions: contents: read id-token: write @@ -70,6 +72,7 @@ jobs: timeout-minutes: 10 name: test runs-on: ${{ github.repository == 'stainless-sdks/kernel-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork steps: - uses: actions/checkout@v4 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index eb5abf7..5b530ee 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.6.4" + ".": "0.6.5" } diff --git a/.stats.yml b/.stats.yml index 9625f4e..d45a618 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 17 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-2eeb61205775c5997abf8154cd6f6fe81a1e83870eff10050b17ed415aa7860b.yml -openapi_spec_hash: 63405add4a3f53718f8183cbb8c1a22f +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-0ac9428eb663361184124cdd6a6e80ae8dc72c927626c949f22aacc4f40095de.yml +openapi_spec_hash: 27707667d706ac33f2d9ccb23c0f15c3 config_hash: 00ec9df250b9dc077f8d3b93a442d252 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3f1a5..2f2b551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.6.5 (2025-07-02) + +Full Changelog: [v0.6.4...v0.6.5](https://github.com/onkernel/kernel-node-sdk/compare/v0.6.4...v0.6.5) + +### Chores + +* **ci:** only run for pushes and fork pull requests ([4b2836c](https://github.com/onkernel/kernel-node-sdk/commit/4b2836ccf9a7e66e43633b9f92b1c24c6fc25ab7)) +* **client:** improve path param validation ([0105321](https://github.com/onkernel/kernel-node-sdk/commit/010532116b7a1ad3bf631e6f7760825ed8142996)) + ## 0.6.4 (2025-06-27) Full Changelog: [v0.6.3...v0.6.4](https://github.com/onkernel/kernel-node-sdk/compare/v0.6.3...v0.6.4) diff --git a/package.json b/package.json index da5efcd..3dfa61d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@onkernel/sdk", - "version": "0.6.4", + "version": "0.6.5", "description": "The official TypeScript library for the Kernel API", "author": "Kernel <>", "types": "dist/index.d.ts", diff --git a/src/internal/uploads.ts b/src/internal/uploads.ts index efffd7a..a4e57ac 100644 --- a/src/internal/uploads.ts +++ b/src/internal/uploads.ts @@ -90,7 +90,7 @@ export const multipartFormRequestOptions = async ( return { ...opts, body: await createForm(opts.body, fetch) }; }; -const supportsFormDataMap = /** @__PURE__ */ new WeakMap>(); +const supportsFormDataMap = /* @__PURE__ */ new WeakMap>(); /** * node-fetch doesn't support the global FormData object in recent node versions. Instead of sending diff --git a/src/internal/utils/log.ts b/src/internal/utils/log.ts index 44d8f65..912b523 100644 --- a/src/internal/utils/log.ts +++ b/src/internal/utils/log.ts @@ -58,7 +58,7 @@ const noopLogger = { debug: noop, }; -let cachedLoggers = /** @__PURE__ */ new WeakMap(); +let cachedLoggers = /* @__PURE__ */ new WeakMap(); export function loggerFor(client: Kernel): Logger { const logger = client.logger; diff --git a/src/internal/utils/path.ts b/src/internal/utils/path.ts index 8abf278..9e1e868 100644 --- a/src/internal/utils/path.ts +++ b/src/internal/utils/path.ts @@ -12,25 +12,43 @@ export function encodeURIPath(str: string) { return str.replace(/[^A-Za-z0-9\-._~!$&'()*+,;=:@]+/g, encodeURIComponent); } +const EMPTY = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.create(null)); + export const createPathTagFunction = (pathEncoder = encodeURIPath) => function path(statics: readonly string[], ...params: readonly unknown[]): string { // If there are no params, no processing is needed. if (statics.length === 1) return statics[0]!; let postPath = false; + const invalidSegments = []; const path = statics.reduce((previousValue, currentValue, index) => { if (/[?#]/.test(currentValue)) { postPath = true; } - return ( - previousValue + - currentValue + - (index === params.length ? '' : (postPath ? encodeURIComponent : pathEncoder)(String(params[index]))) - ); + const value = params[index]; + let encoded = (postPath ? encodeURIComponent : pathEncoder)('' + value); + if ( + index !== params.length && + (value == null || + (typeof value === 'object' && + // handle values from other realms + value.toString === + Object.getPrototypeOf(Object.getPrototypeOf((value as any).hasOwnProperty ?? EMPTY) ?? EMPTY) + ?.toString)) + ) { + encoded = value + ''; + invalidSegments.push({ + start: previousValue.length + currentValue.length, + length: encoded.length, + error: `Value of type ${Object.prototype.toString + .call(value) + .slice(8, -1)} is not a valid path parameter`, + }); + } + return previousValue + currentValue + (index === params.length ? '' : encoded); }, ''); const pathOnly = path.split(/[?#]/, 1)[0]!; - const invalidSegments = []; const invalidSegmentPattern = /(?<=^|\/)(?:\.|%2e){1,2}(?=\/|$)/gi; let match; @@ -39,9 +57,12 @@ export const createPathTagFunction = (pathEncoder = encodeURIPath) => invalidSegments.push({ start: match.index, length: match[0].length, + error: `Value "${match[0]}" can\'t be safely passed as a path parameter`, }); } + invalidSegments.sort((a, b) => a.start - b.start); + if (invalidSegments.length > 0) { let lastEnd = 0; const underline = invalidSegments.reduce((acc, segment) => { @@ -51,7 +72,11 @@ export const createPathTagFunction = (pathEncoder = encodeURIPath) => return acc + spaces + arrows; }, ''); - throw new KernelError(`Path parameters result in path with invalid segments:\n${path}\n${underline}`); + throw new KernelError( + `Path parameters result in path with invalid segments:\n${invalidSegments + .map((e) => e.error) + .join('\n')}\n${path}\n${underline}`, + ); } return path; diff --git a/src/version.ts b/src/version.ts index 67bde7c..d9810da 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.6.4'; // x-release-please-version +export const VERSION = '0.6.5'; // x-release-please-version diff --git a/tests/path.test.ts b/tests/path.test.ts index 6d7177f..b209e96 100644 --- a/tests/path.test.ts +++ b/tests/path.test.ts @@ -1,5 +1,6 @@ import { createPathTagFunction, encodeURIPath } from '@onkernel/sdk/internal/utils/path'; import { inspect } from 'node:util'; +import { runInNewContext } from 'node:vm'; describe('path template tag function', () => { test('validates input', () => { @@ -32,9 +33,114 @@ describe('path template tag function', () => { return testParams.flatMap((e) => rest.map((r) => [e, ...r])); } - // we need to test how %2E is handled so we use a custom encoder that does no escaping + // We need to test how %2E is handled, so we use a custom encoder that does no escaping. const rawPath = createPathTagFunction((s) => s); + const emptyObject = {}; + const mathObject = Math; + const numberObject = new Number(); + const stringObject = new String(); + const basicClass = new (class {})(); + const classWithToString = new (class { + toString() { + return 'ok'; + } + })(); + + // Invalid values + expect(() => rawPath`/a/${null}/b`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Null is not a valid path parameter\n' + + '/a/null/b\n' + + ' ^^^^', + ); + expect(() => rawPath`/a/${undefined}/b`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Undefined is not a valid path parameter\n' + + '/a/undefined/b\n' + + ' ^^^^^^^^^', + ); + expect(() => rawPath`/a/${emptyObject}/b`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Object is not a valid path parameter\n' + + '/a/[object Object]/b\n' + + ' ^^^^^^^^^^^^^^^', + ); + expect(() => rawPath`?${mathObject}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Math is not a valid path parameter\n' + + '?[object Math]\n' + + ' ^^^^^^^^^^^^^', + ); + expect(() => rawPath`/${basicClass}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Object is not a valid path parameter\n' + + '/[object Object]\n' + + ' ^^^^^^^^^^^^^^', + ); + expect(() => rawPath`/../${''}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + + '/../\n' + + ' ^^', + ); + expect(() => rawPath`/../${{}}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + + 'Value of type Object is not a valid path parameter\n' + + '/../[object Object]\n' + + ' ^^ ^^^^^^^^^^^^^^', + ); + + // Valid values + expect(rawPath`/${0}`).toBe('/0'); + expect(rawPath`/${''}`).toBe('/'); + expect(rawPath`/${numberObject}`).toBe('/0'); + expect(rawPath`${stringObject}/`).toBe('/'); + expect(rawPath`/${classWithToString}`).toBe('/ok'); + + // We need to check what happens with cross-realm values, which we might get from + // Jest or other frames in a browser. + + const newRealm = runInNewContext('globalThis'); + expect(newRealm.Object).not.toBe(Object); + + const crossRealmObject = newRealm.Object(); + const crossRealmMathObject = newRealm.Math; + const crossRealmNumber = new newRealm.Number(); + const crossRealmString = new newRealm.String(); + const crossRealmClass = new (class extends newRealm.Object {})(); + const crossRealmClassWithToString = new (class extends newRealm.Object { + toString() { + return 'ok'; + } + })(); + + // Invalid cross-realm values + expect(() => rawPath`/a/${crossRealmObject}/b`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Object is not a valid path parameter\n' + + '/a/[object Object]/b\n' + + ' ^^^^^^^^^^^^^^^', + ); + expect(() => rawPath`?${crossRealmMathObject}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Math is not a valid path parameter\n' + + '?[object Math]\n' + + ' ^^^^^^^^^^^^^', + ); + expect(() => rawPath`/${crossRealmClass}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Object is not a valid path parameter\n' + + '/[object Object]\n' + + ' ^^^^^^^^^^^^^^^', + ); + + // Valid cross-realm values + expect(rawPath`/${crossRealmNumber}`).toBe('/0'); + expect(rawPath`${crossRealmString}/`).toBe('/'); + expect(rawPath`/${crossRealmClassWithToString}`).toBe('/ok'); + const results: { [pathParts: string]: { [params: string]: { valid: boolean; result?: string; error?: string }; @@ -85,6 +191,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' + '/path_params/%2E%2e/a\n' + ' ^^^^^^', }, @@ -92,6 +199,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E" can\'t be safely passed as a path parameter\n' + '/path_params/%2E/a\n' + ' ^^^', }, @@ -103,6 +211,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e%2E" can\'t be safely passed as a path parameter\n' + '/path_params/%2e%2E/\n' + ' ^^^^^^', }, @@ -110,6 +219,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e" can\'t be safely passed as a path parameter\n' + '/path_params/%2e/\n' + ' ^^^', }, @@ -121,6 +231,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E" can\'t be safely passed as a path parameter\n' + '/path_params/%2E\n' + ' ^^^', }, @@ -128,6 +239,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' + '/path_params/%2E%2e\n' + ' ^^^^^^', }, @@ -137,11 +249,17 @@ describe('path template tag function', () => { '["x"]': { valid: true, result: 'x/a' }, '["%2E"]': { valid: false, - error: 'Error: Path parameters result in path with invalid segments:\n%2E/a\n^^^', + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E" can\'t be safely passed as a path parameter\n%2E/a\n^^^', }, '["%2e%2E"]': { valid: false, - error: 'Error: Path parameters result in path with invalid segments:\n' + '%2e%2E/a\n' + '^^^^^^', + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e%2E" can\'t be safely passed as a path parameter\n' + + '%2e%2E/a\n' + + '^^^^^^', }, }, '["","/"]': { @@ -149,11 +267,18 @@ describe('path template tag function', () => { '[""]': { valid: true, result: '/' }, '["%2E%2e"]': { valid: false, - error: 'Error: Path parameters result in path with invalid segments:\n' + '%2E%2e/\n' + '^^^^^^', + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' + + '%2E%2e/\n' + + '^^^^^^', }, '["."]': { valid: false, - error: 'Error: Path parameters result in path with invalid segments:\n./\n^', + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + + './\n^', }, }, '["",""]': { @@ -161,11 +286,17 @@ describe('path template tag function', () => { '["x"]': { valid: true, result: 'x' }, '[".."]': { valid: false, - error: 'Error: Path parameters result in path with invalid segments:\n..\n^^', + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + + '..\n^^', }, '["."]': { valid: false, - error: 'Error: Path parameters result in path with invalid segments:\n.\n^', + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + + '.\n^', }, }, '["a"]': {}, @@ -185,6 +316,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2E" can\'t be safely passed as a path parameter\n' + '/path_params/%2E%2E?beta=true\n' + ' ^^^^^^', }, @@ -192,6 +324,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e%2E" can\'t be safely passed as a path parameter\n' + '/path_params/%2e%2E?beta=true\n' + ' ^^^^^^', }, @@ -203,6 +336,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + '/path_params/.?beta=true\n' + ' ^', }, @@ -210,6 +344,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e." can\'t be safely passed as a path parameter\n' + '/path_params/%2e.?beta=true\n' + ' ^^^^', }, @@ -221,6 +356,8 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + + 'Value "%2e" can\'t be safely passed as a path parameter\n' + '/path_params/./%2e/download\n' + ' ^ ^^^', }, @@ -228,6 +365,8 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' + + 'Value "%2e" can\'t be safely passed as a path parameter\n' + '/path_params/%2E%2e/%2e/download\n' + ' ^^^^^^ ^^^', }, @@ -243,6 +382,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E" can\'t be safely passed as a path parameter\n' + '/path_params/%2E/download\n' + ' ^^^', }, @@ -250,6 +390,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E." can\'t be safely passed as a path parameter\n' + '/path_params/%2E./download\n' + ' ^^^^', }, @@ -261,6 +402,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + '/path_params/./download\n' + ' ^', }, @@ -268,6 +410,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + '/path_params/../download\n' + ' ^^', }, @@ -279,6 +422,7 @@ describe('path template tag function', () => { valid: false, error: 'Error: Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + '/path_params/../download\n' + ' ^^', },