diff --git a/src/constants.mts b/src/constants.mts index c11a58ec..ce6166b0 100644 --- a/src/constants.mts +++ b/src/constants.mts @@ -7,7 +7,7 @@ import { MapCtor, SetCtor } from '@socketsecurity/lib/primordials/map-set' import rootPkgJson from '../package.json' with { type: 'json' } -import { createUserAgentFromPkgJson } from './user-agent.mts' +import { buildSdkBaseUserAgent } from './user-agent.mts' import type { ALERT_ACTION, ALERT_TYPE } from './types.mts' @@ -18,7 +18,7 @@ export { SOCKET_DASHBOARD_URL, } from '@socketsecurity/lib/constants/socket' -export const DEFAULT_USER_AGENT = createUserAgentFromPkgJson(rootPkgJson) +export const DEFAULT_USER_AGENT = buildSdkBaseUserAgent(rootPkgJson) // Default timeout for HTTP requests (30 seconds) export const DEFAULT_HTTP_TIMEOUT = 30_000 diff --git a/src/socket-sdk-class.mts b/src/socket-sdk-class.mts index 7f46263f..8c513566 100644 --- a/src/socket-sdk-class.mts +++ b/src/socket-sdk-class.mts @@ -277,7 +277,9 @@ export class SocketSdk { ...(agent ? { agent } : {}), headers: { Authorization: `Basic ${btoa(`${trimmedToken}:`)}`, - 'User-Agent': userAgent ?? DEFAULT_USER_AGENT, + 'User-Agent': userAgent + ? `${DEFAULT_USER_AGENT} ${userAgent}` + : DEFAULT_USER_AGENT, }, signal: getSdkAbortSignal(), /* c8 ignore next - Optional timeout parameter, tested implicitly through method calls */ diff --git a/src/types.mts b/src/types.mts index 993f2222..bba744ee 100644 --- a/src/types.mts +++ b/src/types.mts @@ -430,7 +430,8 @@ export interface SocketSdkOptions { */ timeout?: number | undefined /** - * Custom User-Agent header. + * User-Agent token appended to the SDK's base User-Agent (which already + * carries the SDK name/version, Node.js version, and OS platform/arch). */ userAgent?: string | undefined } diff --git a/src/user-agent.mts b/src/user-agent.mts index c2c5bccb..9fd3724d 100644 --- a/src/user-agent.mts +++ b/src/user-agent.mts @@ -3,9 +3,35 @@ * headers from package.json data for API requests. */ +import os from 'node:os' +import process from 'node:process' + +/** + * Build the SDK's base User-Agent string, including the SDK name/version, the + * running Node.js version, and the OS platform and architecture. This is used + * as the default User-Agent for API requests; a caller-supplied token (built + * with `createUserAgentFromPkgJson`) is appended to it rather than replacing + * it. + * + * Example output: `socketsecurity-sdk/4.0.4 node/v24.14.1 linux/arm64` + */ +export function buildSdkBaseUserAgent(pkgData: { + name: string + version: string +}): string { + const name = pkgData.name.replace('@', '').replace('/', '-') + return [ + `${name}/${pkgData.version}`, + `node/${process.version}`, + `${os.platform()}/${os.arch()}`, + ].join(' ') +} + /** * Generate a User-Agent string from package.json data. Creates standardized - * User-Agent format with optional homepage URL. + * User-Agent format with optional homepage URL. Pass the result as `userAgent` + * in `SocketSdkOptions` to identify your application; it is appended to the + * SDK's own base User-Agent string. */ export function createUserAgentFromPkgJson(pkgData: { name: string diff --git a/test/repo/unit/getapi-sendapi-methods.test.mts b/test/repo/unit/getapi-sendapi-methods.test.mts index b78c7012..aa35f84d 100644 --- a/test/repo/unit/getapi-sendapi-methods.test.mts +++ b/test/repo/unit/getapi-sendapi-methods.test.mts @@ -5,6 +5,7 @@ import nock from 'nock' import { describe, expect, it } from 'vitest' +import { DEFAULT_USER_AGENT } from '../../../src/constants.mts' import { SocketSdk } from '../../../src/index.mts' import { setupTestClient } from '../../utils/environment.mts' @@ -288,7 +289,7 @@ describe('getApi and sendApi Methods', () => { } }) - it('should use custom User-Agent from SDK options', async () => { + it('should append custom User-Agent from SDK options to the SDK base', async () => { const customClient = new SocketSdk('test-token', { userAgent: 'CustomApp/1.0.0', }) @@ -305,7 +306,11 @@ describe('getApi and sendApi Methods', () => { throws: false, }) - expect(capturedHeaders['user-agent']).toBe('CustomApp/1.0.0') + // The caller token is appended to the enriched SDK base, not swapped in. + expect(capturedHeaders['user-agent']).toBe( + `${DEFAULT_USER_AGENT} CustomApp/1.0.0`, + ) + expect(capturedHeaders['user-agent']).toContain('node/') }) it('should work with custom base URLs', async () => { diff --git a/test/repo/unit/user-agent.test.mts b/test/repo/unit/user-agent.test.mts index e2fda04a..23b56562 100644 --- a/test/repo/unit/user-agent.test.mts +++ b/test/repo/unit/user-agent.test.mts @@ -7,10 +7,16 @@ * through verbatim. */ +import os from 'node:os' +import process from 'node:process' + import fc from 'fast-check' import { describe, expect, test } from 'vitest' -import { createUserAgentFromPkgJson } from '../../../src/user-agent.mts' +import { + buildSdkBaseUserAgent, + createUserAgentFromPkgJson, +} from '../../../src/user-agent.mts' // A plain (unscoped) package-name fragment: alnum + dashes + dots, no '@'/'/'. const plainName = fc @@ -76,3 +82,43 @@ describe('user-agent/createUserAgentFromPkgJson (fuzz)', () => { ) }) }) + +describe('user-agent/buildSdkBaseUserAgent', () => { + // DERIVED-FROM-INPUT: the base UA is the SDK name/version enriched with the + // running Node.js version and the OS platform/arch, space-separated in a + // stable order. Fields are read from node:process / node:os so the expected + // value is knowable at test time. + test('formats "name/version node/ /"', () => { + fc.assert( + fc.property(plainName, version, (name, ver) => { + expect(buildSdkBaseUserAgent({ name, version: ver })).toBe( + `${name}/${ver} node/${process.version} ${os.platform()}/${os.arch()}`, + ) + }), + ) + }) + + // Enrichment tokens are always present regardless of the pkg fields. + test('includes the node version and the OS platform/arch tokens', () => { + const ua = buildSdkBaseUserAgent({ + name: 'socketsecurity-sdk', + version: '4.0.4', + }) + expect(ua).toContain(`node/${process.version}`) + expect(ua).toContain(`${os.platform()}/${os.arch()}`) + }) + + // NORMALIZER: a scoped name is normalized the same way as the caller-token + // builder (@scope/pkg -> scope-pkg). + test('normalizes a scoped @scope/pkg name to scope-pkg', () => { + fc.assert( + fc.property(plainName, plainName, version, (scope, pkg, ver) => { + expect( + buildSdkBaseUserAgent({ name: `@${scope}/${pkg}`, version: ver }), + ).toBe( + `${scope}-${pkg}/${ver} node/${process.version} ${os.platform()}/${os.arch()}`, + ) + }), + ) + }) +})