diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..10c52b8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +src/generated/api.ts text eol=lf +resources/shieldlabs-api.yaml text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71575a6..1d69cf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,13 +25,14 @@ jobs: node-version: ${{ matrix.node }} cache: npm - run: npm ci - - run: npm run generate - - run: git diff --exit-code -- src/generated/api.ts + - run: npm run generate:check - run: npm run typecheck + - run: npm run test:type-drift - run: npm run lint - run: npm test -- --coverage - run: npm run build - run: npm run test:package + - run: npm run test:consumer - run: npm run test:runtime - run: npm run test:example diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index edccba5..25c5021 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,11 +35,14 @@ jobs: exit 1 fi echo "version=${version}" >> "$GITHUB_OUTPUT" + - run: npm run generate:check - run: npm run typecheck + - run: npm run test:type-drift - run: npm run lint - run: npm test - run: npm run build - run: npm run test:package + - run: npm run test:consumer - run: npm run test:runtime - run: npm pack - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index c562d3c..b4f2c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,19 @@ All notable changes to this package are documented in this file. The format foll [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.0.2] - 2026-10-05 + +### Changed + +- Derive HTTP request parameters, response models and normalized model fields from the generated + OpenAPI types. Check History column names against the schema while preserving normalization, + unknown fields and open string values. + +### Added + +- Generated-type freshness and schema-change compilation checks in CI and release validation. +- Isolated packed-consumer checks for ESM, CommonJS and bundler types, runtime behavior and + agreement between the package version, declarations and runtime `VERSION`. ## [1.0.1] - 2026-10-01 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8050320..6919b08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,17 +15,26 @@ npm ci Every change must pass these before it is merged: ```bash +npm run generate:check # types match the checked-in OpenAPI description; does not write files npm run typecheck +npm run test:type-drift # regenerate changed schemas in memory and compile SDK source npm run lint npm test -- --coverage # coverage must stay at or above 90% npm run build npm run test:package # export conditions, edge bundles, published files +npm run test:consumer # isolated tarball install, ESM/CJS declarations and VERSION npm run test:runtime # the built package in the Node.js and edge entries npm run test:example # examples/node-http end to end ``` ## Guidelines +- Update HTTP shapes through `resources/shieldlabs-api.yaml` and `npm run generate`, not by + editing `src/generated/api.ts`. `./sync.sh` downloads the API description when you want to + refresh it. Commit the description and generated file together. +- `src/wire.ts` selects the operations used by this SDK. Normalized models derive unchanged + fields from these types; JSON remains untrusted until the normalizers inspect it. Keep + support for unknown fields and string values, missing flags and invalid timestamps. - Keep the package free of runtime dependencies. - Only `src/index.ts` may import `node:crypto`. Everything shared by both entries (`src/common.ts` and what it imports) must run on `fetch` and WebCrypto alone; `npm run test:package` checks it. diff --git a/README.md b/README.md index 9838965..75f1807 100644 --- a/README.md +++ b/README.md @@ -469,18 +469,30 @@ client is created, without repeating the value. ## Development ```bash -./sync.sh # download the current OpenAPI description -npm run generate # rebuild src/generated/api.ts from that file npm ci +npm run generate:check # verify the checked-in types without changing them npm run typecheck +npm run test:type-drift # incompatible schema changes must fail SDK compilation npm run lint npm test -- --coverage npm run build npm run test:package # export conditions, edge bundles, published files +npm run test:consumer # install the tarball; check ESM/CJS types and runtime versions npm run test:runtime # built package, Node.js entry and edge entry npm run test:example # examples/node-http end to end ``` +The SDK derives its HTTP parameter and response types from `src/generated/api.ts`. Normalized +identifications still add `source`, `raw` and signal descriptions, and use `null` for timestamps +that cannot be parsed. JSON is checked at runtime; unknown response fields remain in `raw`, and +new string values are accepted. + +To update the API description, run `./sync.sh` and then `npm run generate`. Review both the +description and generated changes, adapt any affected normalization or validation, and run the +checks above. CI checks freshness against the committed description without fetching a changing +remote schema. Consumers do not need the generator or the schema file: declarations are bundled +into the published package. + See [CONTRIBUTING.md](CONTRIBUTING.md). Documentation: [docs.shieldlabs.ai](https://docs.shieldlabs.ai). Support: [contact@shieldlabs.ai](mailto:contact@shieldlabs.ai). diff --git a/generate.mjs b/generate.mjs index 60575b3..f0ae18f 100644 --- a/generate.mjs +++ b/generate.mjs @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -7,13 +7,30 @@ import openapiTS, { astToString } from 'openapi-typescript'; const root = resolve(dirname(fileURLToPath(import.meta.url))); const outputPath = resolve(root, 'src/generated/api.ts'); -const ast = await openapiTS(new URL('./resources/shieldlabs-api.yaml', import.meta.url)); const banner = `/** * Generated from resources/shieldlabs-api.yaml. Do not edit by hand. * Refresh with: ./sync.sh && npm run generate */ `; -mkdirSync(dirname(outputPath), { recursive: true }); -writeFileSync(outputPath, banner + astToString(ast)); -console.log(`Wrote ${outputPath}`); +export async function generateApiTypes( + schema = new URL('./resources/shieldlabs-api.yaml', import.meta.url), +) { + return banner + astToString(await openapiTS(schema)); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const generated = await generateApiTypes(); + if (process.argv.includes('--check')) { + if (!existsSync(outputPath) || readFileSync(outputPath, 'utf8') !== generated) { + console.error('src/generated/api.ts is stale. Run npm run generate and commit the result.'); + process.exitCode = 1; + } else { + console.log('Generated API types are up to date.'); + } + } else { + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, generated); + console.log(`Wrote ${outputPath}`); + } +} diff --git a/package-lock.json b/package-lock.json index 83b2f9e..2e636bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@shieldlabs-ai/node", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@shieldlabs-ai/node", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.5", diff --git a/package.json b/package.json index defedc9..3709475 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shieldlabs-ai/node", - "version": "1.0.1", + "version": "1.0.2", "description": "ShieldLabs server SDK for Node.js and edge runtimes: History API and Management API clients, webhook signature verification, typed identifications and risk helpers.", "license": "MIT", "author": "ShieldLabs Inc. ", @@ -117,10 +117,13 @@ "format": "prettier --write .", "test": "vitest run", "test:package": "node scripts/check-package.mjs", + "test:consumer": "node scripts/check-consumer.mjs", + "test:type-drift": "node scripts/check-type-drift.mjs", "test:runtime": "node test/runtime/smoke.mjs && node --conditions=worker test/runtime/smoke.mjs", "test:example": "node scripts/smoke-example.mjs", "sync": "./sync.sh", "generate": "node generate.mjs", + "generate:check": "node generate.mjs --check", "prepack": "npm run build" }, "publishConfig": { diff --git a/scripts/check-consumer.mjs b/scripts/check-consumer.mjs new file mode 100644 index 0000000..28f4362 --- /dev/null +++ b/scripts/check-consumer.mjs @@ -0,0 +1,138 @@ +// Install the actual tarball in an isolated project, with no workspace or sibling dependencies. +// Run after build. Deliberately skip prepack so stale dist files fail rather than being repaired. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const temporary = mkdtempSync(join(tmpdir(), 'shieldlabs-node-consumer-')); +const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); +const npmCli = process.env.npm_execpath; +function npm(args, cwd) { + const commandArgs = ['--cache', join(temporary, 'npm-cache'), ...args]; + return execFileSync( + npmCli ? process.execPath : 'npm', + npmCli ? [npmCli, ...commandArgs] : commandArgs, + { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); +} + +try { + const [packed] = JSON.parse( + npm(['pack', '--ignore-scripts', '--json', '--pack-destination', temporary], root), + ); + writeFileSync( + join(temporary, 'package.json'), + JSON.stringify({ name: 'sdk-consumer-test', private: true, type: 'module' }), + ); + npm( + [ + 'install', + join(temporary, packed.filename), + '--offline', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + ], + temporary, + ); + const installedManifest = JSON.parse( + readFileSync(join(temporary, 'node_modules/@shieldlabs-ai/node/package.json'), 'utf8'), + ); + assert.equal(installedManifest.version, manifest.version); + const fixture = readFileSync(join(root, 'test/consumer/usage.ts.txt'), 'utf8'); + const consumer = `${fixture}\nconst packageVersion: ${JSON.stringify(manifest.version)} = VERSION;\nvoid packageVersion;\n`; + for (const [filename, module, resolution] of [ + ['consumer.mts', 'NodeNext', 'NodeNext'], + ['consumer.cts', 'NodeNext', 'NodeNext'], + ['consumer.ts', 'ESNext', 'Bundler'], + ]) { + writeFileSync(join(temporary, filename), consumer); + execFileSync( + process.execPath, + [ + join(root, 'node_modules/typescript/bin/tsc'), + filename, + '--strict', + '--exactOptionalPropertyTypes', + '--noUncheckedIndexedAccess', + '--noEmit', + '--target', + 'ES2022', + '--lib', + 'ES2022,DOM', + '--module', + module, + '--moduleResolution', + resolution, + ], + { cwd: temporary, encoding: 'utf8', stdio: 'pipe' }, + ); + console.log(`ok packed declarations: ${filename} (${resolution})`); + } + + const runtime = ` +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { ShieldLabs, ShieldLabsManagement, VERSION } from '@shieldlabs-ai/node'; +const require = createRequire(import.meta.url); +const manifest = JSON.parse(readFileSync(require.resolve('@shieldlabs-ai/node/package.json'), 'utf8')); +assert.equal(VERSION, manifest.version); +assert.equal(require('@shieldlabs-ai/node').VERSION, manifest.version); +const row = { request_id: 'a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d', score: 999, connection_type: 'future_connection', future_field: true }; +const client = new ShieldLabs({ apiKey: 'sec_12345678-12345678-12345678', fetch: async () => new Response(JSON.stringify({ data: [row], total: 1 })) }); +const page = await client.history.search('user_hid', 'example', { limit: 1 }); +assert.equal(page.total, 1); +assert.equal(page.data[0].risk_score, 999); +assert.equal(page.data[0].connection_type, 'future_connection'); +assert.equal(page.data[0].observed_at, null); +assert.equal(page.data[0].detection_flags.vpn, false); +assert.equal(page.data[0].raw.future_field, true); +const management = new ShieldLabsManagement({ secretKey: 'example_secret', domain: 'example.com', fetch: async () => new Response(JSON.stringify({ Domain: 'example.com', Weight: -1, PublicKey: '****1234', Secret: '****5678', CreatedAt: '', future_field: true })) }); +const profile = await management.getProfile(); +assert.equal(profile.remaining_identifications, -1); +assert.equal(profile.created_at, null); +assert.equal(profile.raw.future_field, true); +console.log('ok packed runtime and VERSION: ' + VERSION); +`; + writeFileSync(join(temporary, 'runtime.mjs'), runtime); + for (const conditions of [[], ['--conditions=worker']]) { + const output = execFileSync(process.execPath, [...conditions, 'runtime.mjs'], { + cwd: temporary, + encoding: 'utf8', + }); + console.log(output.trim()); + } + + // Prove the runtime guard rejects stale dist rather than accepting the manifest alone. + // Only the disposable installed copy is changed; the source and build are untouched. + const installedEntry = join(temporary, 'node_modules/@shieldlabs-ai/node/dist/index.js'); + const entry = readFileSync(installedEntry, 'utf8'); + const versionDeclaration = `var VERSION = ${JSON.stringify(manifest.version)};`; + assert.equal(entry.split(versionDeclaration).length, 2); + writeFileSync(installedEntry, entry.replace(versionDeclaration, 'var VERSION = "0.0.0-stale";')); + assert.throws( + () => + execFileSync(process.execPath, ['runtime.mjs'], { + cwd: temporary, + encoding: 'utf8', + stdio: 'pipe', + }), + (error) => + error.status === 1 && + error.stderr.includes('ERR_ASSERTION') && + error.stderr.includes('0.0.0-stale'), + ); + console.log('ok a stale runtime VERSION in the installed tarball is rejected'); +} finally { + rmSync(temporary, { recursive: true, force: true }); +} diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 2f5c91a..f339f59 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -4,6 +4,9 @@ import { existsSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('..', import.meta.url)); +const version = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +).version; const failures = []; function check(label, condition) { console.log(`${condition ? 'ok ' : 'FAIL'} ${label}`); @@ -42,6 +45,11 @@ const files = ['index.js', 'index.cjs', 'index.d.ts', 'index.d.cts', 'edge.js', for (const file of files) check(`dist/${file} exists`, existsSync(new URL(`../dist/${file}`, import.meta.url))); +for (const file of ['index.js', 'index.cjs', 'edge.js', 'edge.cjs']) { + const built = await import(new URL(`../dist/${file}`, import.meta.url)); + check(`dist/${file} VERSION matches package.json (${version})`, built.VERSION === version); +} + // The edge build must never load a Node.js built-in. const builtinImport = /(?:from\s*|import\s*\(\s*|require\s*\(\s*)["'](?:node:[\w/]+|crypto|buffer|fs|http|https|stream|util)["']/; diff --git a/scripts/check-type-drift.mjs b/scripts/check-type-drift.mjs new file mode 100644 index 0000000..bdcd6a4 --- /dev/null +++ b/scripts/check-type-drift.mjs @@ -0,0 +1,147 @@ +// Regenerate types after changing the schema in memory, then typecheck SDK source against them. +// No test assertions or edited working files can cause the expected compilation failure. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { generateApiTypes } from '../generate.mjs'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const generatedPath = resolve(root, 'src/generated/api.ts'); +const schema = readFileSync(resolve(root, 'resources/shieldlabs-api.yaml'), 'utf8'); +const config = ts.readConfigFile(resolve(root, 'tsconfig.json'), ts.sys.readFile); +assert.equal(config.error, undefined); +const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root); +assert.deepEqual(parsed.errors, []); +const rootNames = parsed.fileNames.filter((name) => + resolve(name).startsWith(resolve(root, 'src') + sep), +); + +function compile(generated) { + const host = ts.createCompilerHost(parsed.options); + const originalRead = host.readFile.bind(host); + host.readFile = (name) => (resolve(name) === generatedPath ? generated : originalRead(name)); + const program = ts.createProgram({ rootNames, options: parsed.options, host }); + return ts.getPreEmitDiagnostics(program); +} + +function replaceOnce(text, from, to) { + assert.equal(text.split(from).length, 2, `Expected one occurrence of ${JSON.stringify(from)}`); + return text.replace(from, to); +} + +function changeComponent(name, from, to) { + const start = schema.indexOf(`\n ${name}:\n`); + assert.ok(start >= 0, `Missing component ${name}`); + const rest = schema.slice(start + 1); + const next = rest.search(/\n {4}[A-Za-z][A-Za-z0-9]*:\n/); + assert.ok(next >= 0, `Missing component boundary after ${name}`); + const end = start + 1 + next; + return ( + schema.slice(0, start) + replaceOnce(schema.slice(start, end), from, to) + schema.slice(end) + ); +} + +const baseline = await generateApiTypes(schema); +assert.equal(baseline, readFileSync(generatedPath, 'utf8'), 'Run npm run generate first.'); +assert.deepEqual(compile(baseline), [], 'The unchanged SDK must compile before testing drift.'); +console.log('ok unchanged schema compiles'); + +const cases = [ + [ + 'A new lookup type needs validation support', + changeComponent( + 'HistorySearchType', + ' - cookie_id\n', + ' - cookie_id\n - account_id\n', + ), + 'src/validation.ts', + ], + [ + 'History score column renamed', + changeComponent('HistoryRow', '\n score:\n', '\n risk_points:\n'), + 'src/normalize.ts', + ], + [ + 'History score changes type', + changeComponent( + 'HistoryRow', + "score:\n $ref: '#/components/schemas/RiskScore'", + 'score:\n type: string', + ), + 'src/normalize.ts', + ], + [ + 'History page total changes type', + changeComponent( + 'HistoryPage', + 'total:\n type: integer', + 'total:\n type: string', + ), + 'src/history.ts', + ], + [ + 'Profile weight changes type', + changeComponent( + 'DomainProfile', + 'Weight:\n type: integer', + 'Weight:\n type: string', + ), + 'src/normalize.ts', + ], + [ + 'History limit changes type', + changeComponent('HistoryLimit', 'type: integer', 'type: string'), + 'src/history.ts', + ], + [ + 'History query parameter renamed', + changeComponent('HistoryLimit', 'name: limit', 'name: page_size'), + 'src/history.ts', + ], + [ + 'Management domain header renamed', + changeComponent('ShieldDomain', 'name: X-Shield-Domain', 'name: X-Site-Domain'), + 'src/management.ts', + ], + [ + 'Webhook score changes type', + changeComponent( + 'IdentificationScoredData', + "risk_score:\n $ref: '#/components/schemas/RiskScore'", + 'risk_score:\n type: string', + ), + 'src/normalize.ts', + ], + [ + 'A new flag needs a normalization rule', + changeComponent( + 'DetectionFlags', + ' properties:\n', + ' properties:\n extra_flag:\n type: boolean\n', + ), + 'src/normalize.ts', + ], +]; + +for (const [label, changedSchema, expectedFile] of cases) { + const changedTypes = await generateApiTypes(changedSchema); + assert.notEqual(changedTypes, baseline, `${label}: regeneration must change the types`); + const diagnostics = compile(changedTypes); + const relevant = diagnostics.filter( + (diagnostic) => + diagnostic.file && resolve(diagnostic.file.fileName) === resolve(root, expectedFile), + ); + assert.ok(relevant.length > 0, `${label}: SDK code must fail to compile in ${expectedFile}`); + console.log(`ok ${label}: ${expectedFile} rejects the incompatible schema`); +} + +// Additive fields remain valid raw data; strings used for server values remain open. +const additive = changeComponent( + 'HistoryRow', + ' properties:\n', + ' properties:\n future_diagnostic:\n type: string\n', +); +assert.deepEqual(compile(await generateApiTypes(additive)), []); +console.log('ok additive History fields remain compatible'); diff --git a/src/constants.ts b/src/constants.ts index ec50236..b4ba4c8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -40,7 +40,7 @@ export const RISK_BANDS = Object.freeze({ export const RISK_BAND_NAMES: readonly RiskBand[] = ['trusted', 'suspicious', 'dangerous']; -export const LOOKUP_TYPES: readonly LookupType[] = [ +export const LOOKUP_TYPES = [ 'ip', 'user_hid', 'visitor_id', @@ -48,7 +48,7 @@ export const LOOKUP_TYPES: readonly LookupType[] = [ 'device_id', 'session_id', 'cookie_id', -]; +] as const satisfies readonly LookupType[]; export type FlagKey = keyof DetectionFlags; diff --git a/src/history.ts b/src/history.ts index 9f9dc88..90b72f5 100644 --- a/src/history.ts +++ b/src/history.ts @@ -12,6 +12,7 @@ import { RATE_LIMIT_FLOOR_MS, RETRY_AFTER_CAP_MS, Transport, normalizeBaseUrl } import { fromHistoryRow, isPlainObject } from './normalize.js'; import { sleep, throwIfAborted, warnIfBrowserPage, warnOnce } from './runtime.js'; import type { FetchLike, HistoryPage, Identification, LookupType } from './types.js'; +import type { HistoryPath, HistoryQuery } from './wire.js'; import { validateBoolean, validateCredential, @@ -45,16 +46,16 @@ export interface ShieldLabsOptions { /** Options of `history.search`. */ export interface SearchOptions { /** Page size from 1 to 100. Default 20. */ - limit?: number | undefined; + limit?: HistoryQuery['limit']; /** Number of rows to skip. Default 0. */ - offset?: number | undefined; + offset?: HistoryQuery['offset']; signal?: AbortSignal | undefined; } /** Options of `history.iterate`. */ export interface IterateOptions { /** Rows per request from 1 to 100. Default 100. */ - pageSize?: number | undefined; + pageSize?: HistoryQuery['limit']; /** Stop after this many identifications. Default: no limit. */ maxItems?: number | undefined; signal?: AbortSignal | undefined; @@ -130,15 +131,17 @@ function parsePage(body: unknown, status: number, headers: ApiError['headers']): async function fetchPage( transport: Transport, lookup: Lookup, - limit: number, - offset: number, + limit: NonNullable, + offset: NonNullable, signal: AbortSignal | undefined, maxRetries?: number, attemptTimeout?: number, ): Promise { - const path = `/api/v1/history/${lookup.type}/${lookup.segment}`; + const pathParams: HistoryPath = { search_type: lookup.type, value: lookup.segment }; + const query: HistoryQuery = { limit, offset }; + const path = `/api/v1/history/${pathParams.search_type}/${pathParams.value}`; const response = await transport.getJson(path, { - query: { limit: String(limit), offset: String(offset) }, + query: { limit: String(query.limit), offset: String(query.offset) }, signal, maxRetries, timeout: attemptTimeout, @@ -168,7 +171,11 @@ export class HistoryResource { * One page of identifications for one identifier, newest first. * `GET /api/v1/history/{type}/{value}`. */ - async search(type: LookupType, value: string, options: SearchOptions = {}): Promise { + async search( + type: LookupType, + value: HistoryPath['value'], + options: SearchOptions = {}, + ): Promise { const lookup = validateLookup(type, value); const limit = validatePageSize(options.limit ?? DEFAULT_LIMIT, 'limit'); const offset = validateNonNegativeInteger(options.offset ?? 0, 'offset'); @@ -182,7 +189,7 @@ export class HistoryResource { */ iterate( type: LookupType, - value: string, + value: HistoryPath['value'], options: IterateOptions = {}, ): AsyncGenerator { const lookup = validateLookup(type, value); diff --git a/src/management.ts b/src/management.ts index fa1c652..0a684a5 100644 --- a/src/management.ts +++ b/src/management.ts @@ -4,6 +4,7 @@ import { Transport, normalizeBaseUrl } from './http.js'; import { isPlainObject, profileFromResponse } from './normalize.js'; import { warnIfBrowserPage } from './runtime.js'; import type { DomainProfile, FetchLike } from './types.js'; +import type { ProfileHeaders } from './wire.js'; import { isAscii, requireHeaderSafe, @@ -18,7 +19,7 @@ export interface ShieldLabsManagementOptions { /** Secret Key of the domain. Keep it on your server. */ secretKey: string; /** The registered domain, for example "example.com". Normalized before it is sent. */ - domain: string; + domain: ProfileHeaders['X-Shield-Domain']; /** Origin of the Management API. Default https://api.shieldlabs.ai. */ baseUrl?: string | undefined; /** Timeout of one HTTP attempt in milliseconds. Default 10 000. */ @@ -61,7 +62,7 @@ export function normalizeDomain(input: string): string { */ export class ShieldLabsManagement { /** The normalized domain sent in X-Shield-Domain. */ - readonly domain: string; + readonly domain: ProfileHeaders['X-Shield-Domain']; readonly #transport: Transport; constructor(options: ShieldLabsManagementOptions) { @@ -98,10 +99,14 @@ export class ShieldLabsManagement { throw new ValidationError('fetch must be a function.'); } this.domain = domain; + const headers = { + 'X-Shield-Domain': domain, + Authorization: `Bearer ${secretKey}`, + } satisfies ProfileHeaders & { Authorization: string }; this.#transport = new Transport({ apiName: 'Management API', baseUrl, - headers: { 'X-Shield-Domain': domain, Authorization: `Bearer ${secretKey}` }, + headers, secrets: [secretKey], timeout, maxRetries, diff --git a/src/normalize.ts b/src/normalize.ts index 8d85683..9aac55d 100644 --- a/src/normalize.ts +++ b/src/normalize.ts @@ -16,6 +16,7 @@ import type { IpInfo, TrafficSource, } from './types.js'; +import type { HistoryRow, ScoreDetail, ScoredData, WireKeys } from './wire.js'; export type JsonObject = Record; @@ -243,7 +244,7 @@ export function parseRfc3339(value: unknown): string | null { type HistoryFlagKey = Exclude; -const HISTORY_FLAG_COLUMNS: Readonly> = { +const HISTORY_FLAG_COLUMNS: Readonly>> = { vpn: 'is_vpn', privacy_relay: 'is_privacy_relay', tor: 'is_tor', @@ -285,6 +286,18 @@ function normalizeScore(obj: JsonObject, key: string): number { return typeof value === 'number' ? value : Number.NaN; } +// JSON is untrusted even when an HTTP operation has generated types. These readers check +// column names and expected wire types at compile time, while retaining runtime fallbacks +// for missing or malformed fields and keeping unknown fields in `raw`. +const historyField: (obj: JsonObject, key: WireKeys) => unknown = own; +const historyString: (obj: JsonObject, key: WireKeys) => string = stringField; +const historyScore: (obj: JsonObject, key: WireKeys) => number = normalizeScore; +const webhookField: (obj: JsonObject, key: WireKeys) => unknown = own; +const webhookString: (obj: JsonObject, key: WireKeys) => string = stringField; +const webhookScore: (obj: JsonObject, key: WireKeys) => number = normalizeScore; +const detailString: (obj: JsonObject, key: WireKeys) => string = stringField; +const detailNumber: (obj: JsonObject, key: WireKeys) => unknown = own; + /** Parses the JSON-encoded `score_details` string. "" or invalid JSON gives an empty list. */ function parseScoreDetails(value: unknown): unknown[] { const text = isTruthy(value) ? value : '[]'; @@ -316,79 +329,79 @@ const TRAFFIC_KEYS: ReadonlyArray = [ /** Builds an Identification from one History API row. */ export function fromHistoryRow(row: JsonObject): Identification { - const leakSource = stripWhitespace(stringField(row, 'webrtc_leak_source')); + const leakSource = stripWhitespace(historyString(row, 'webrtc_leak_source')); let localIp: string; let localCountry: string; if (leakSource !== '' && leakSource !== 'none') { - localIp = normalizeIp(own(row, 'webrtc_leak_ip')); - localCountry = stringField(row, 'webrtc_leak_country'); + localIp = normalizeIp(historyField(row, 'webrtc_leak_ip')); + localCountry = historyString(row, 'webrtc_leak_country'); } else { - localIp = normalizeIp(own(row, 'web_rtc_ip')); - localCountry = stringField(row, 'web_rtc_country'); + localIp = normalizeIp(historyField(row, 'web_rtc_ip')); + localCountry = historyString(row, 'web_rtc_country'); } - const publicIp = normalizeIp(own(row, 'ip')); + const publicIp = normalizeIp(historyField(row, 'ip')); const signals: IdentificationSignal[] = []; let ipLeakDetail = false; - for (const entry of parseScoreDetails(own(row, 'score_details'))) { + for (const entry of parseScoreDetails(historyField(row, 'score_details'))) { if (!isPlainObject(entry)) continue; - const description = stringField(entry, 'Description'); + const description = detailString(entry, 'Description'); if (description.startsWith('IP ≠ leakIP')) ipLeakDetail = true; - const value = own(entry, 'Value'); + const value = detailNumber(entry, 'Value'); if (typeof value !== 'number' || !Number.isInteger(value) || value === 0) continue; signals.push({ name: signalSlug(description), weight: value, description }); } - const searchBot = isTruthy(own(row, 'is_search_bot')); + const searchBot = isTruthy(historyField(row, 'is_search_bot')); const flags = {} as DetectionFlags; for (const key of FLAG_KEYS) { if (key === 'browser_vpn_proxy') { - flags[key] = own(row, 'connection_type') === 'browser_vpn_proxy'; + flags[key] = historyField(row, 'connection_type') === 'browser_vpn_proxy'; } else if (key === 'ip_mismatch') { flags[key] = !searchBot && (ipLeakDetail || (publicIp !== '' && localIp !== '' && publicIp !== localIp)); } else { - flags[key] = isTruthy(own(row, HISTORY_FLAG_COLUMNS[key])); + flags[key] = isTruthy(historyField(row, HISTORY_FLAG_COLUMNS[key])); } } - const siteDomain = own(row, 'site_domain'); + const siteDomain = historyField(row, 'site_domain'); let domain: string; if (isTruthy(siteDomain)) { domain = typeof siteDomain === 'string' ? siteDomain : ''; } else { - domain = stringField(row, 'domain'); + domain = historyString(row, 'domain'); } return { - request_id: stringField(row, 'request_id'), - visitor_id: stringField(row, 'visitor_id'), - device_id: stringField(row, 'device_id'), - session_id: stringField(row, 'session_id'), - cookie_id: stringField(row, 'cookie_id'), - user_hid: normalizeUserHid(own(row, 'user_hid')), + request_id: historyString(row, 'request_id'), + visitor_id: historyString(row, 'visitor_id'), + device_id: historyString(row, 'device_id'), + session_id: historyString(row, 'session_id'), + cookie_id: historyString(row, 'cookie_id'), + user_hid: normalizeUserHid(historyField(row, 'user_hid')), domain, - public_ip: { ip: publicIp, country: stringField(row, 'country') }, + public_ip: { ip: publicIp, country: historyString(row, 'country') }, local_ip: { ip: localIp, country: localCountry }, - connection_type: stringField(row, 'connection_type'), - os: stringField(row, 'os'), - browser: stringField(row, 'browser'), - device_type: stringField(row, 'device_type'), + connection_type: historyString(row, 'connection_type'), + os: historyString(row, 'os'), + browser: historyString(row, 'browser'), + device_type: historyString(row, 'device_type'), traffic_source: { - channel: stringField(row, 'traffic_channel'), - referrer_domain: stringField(row, 'referrer_domain'), - landing_url: stringField(row, 'entry_url'), - click_id_type: stringField(row, 'click_id_type'), - utm_source: stringField(row, 'utm_source'), - utm_medium: stringField(row, 'utm_medium'), - utm_campaign: stringField(row, 'utm_campaign'), - utm_content: stringField(row, 'utm_content'), - utm_term: stringField(row, 'utm_term'), + channel: historyString(row, 'traffic_channel'), + referrer_domain: historyString(row, 'referrer_domain'), + landing_url: historyString(row, 'entry_url'), + click_id_type: historyString(row, 'click_id_type'), + utm_source: historyString(row, 'utm_source'), + utm_medium: historyString(row, 'utm_medium'), + utm_campaign: historyString(row, 'utm_campaign'), + utm_content: historyString(row, 'utm_content'), + utm_term: historyString(row, 'utm_term'), }, - risk_score: normalizeScore(row, 'score'), + risk_score: historyScore(row, 'score'), signals, detection_flags: flags, - observed_at: parseHistoryTime(own(row, 'created_at')), + observed_at: parseHistoryTime(historyField(row, 'created_at')), source: 'history', raw: row, }; @@ -396,17 +409,17 @@ export function fromHistoryRow(row: JsonObject): Identification { /** Builds an Identification from the `data` object of an `identification.scored` webhook. */ export function fromWebhookData(data: JsonObject): Identification { - const rawFlags = own(data, 'detection_flags'); + const rawFlags = webhookField(data, 'detection_flags'); const flagSource = isPlainObject(rawFlags) ? rawFlags : {}; const flags = {} as DetectionFlags; for (const key of FLAG_KEYS) flags[key] = isTruthy(own(flagSource, key)); - const rawTraffic = own(data, 'traffic_source'); + const rawTraffic = webhookField(data, 'traffic_source'); const trafficSource = isPlainObject(rawTraffic) ? rawTraffic : {}; const traffic = {} as TrafficSource; for (const key of TRAFFIC_KEYS) traffic[key] = stringField(trafficSource, key); - const rawSignals = own(data, 'signals'); + const rawSignals = webhookField(data, 'signals'); const signals: IdentificationSignal[] = Array.isArray(rawSignals) ? rawSignals.filter(isPlainObject).map((signal) => { const weight = own(signal, 'weight'); @@ -419,24 +432,24 @@ export function fromWebhookData(data: JsonObject): Identification { : []; return { - request_id: stringField(data, 'request_id'), - visitor_id: stringField(data, 'visitor_id'), - device_id: stringField(data, 'device_id'), - session_id: stringField(data, 'session_id'), - cookie_id: stringField(data, 'cookie_id'), - user_hid: normalizeUserHid(own(data, 'user_hid')), - domain: stringField(data, 'domain'), - public_ip: ipInfo(own(data, 'public_ip')), - local_ip: ipInfo(own(data, 'local_ip')), - connection_type: stringField(data, 'connection_type'), - os: stringField(data, 'os'), - browser: stringField(data, 'browser'), - device_type: stringField(data, 'device_type'), + request_id: webhookString(data, 'request_id'), + visitor_id: webhookString(data, 'visitor_id'), + device_id: webhookString(data, 'device_id'), + session_id: webhookString(data, 'session_id'), + cookie_id: webhookString(data, 'cookie_id'), + user_hid: normalizeUserHid(webhookField(data, 'user_hid')), + domain: webhookString(data, 'domain'), + public_ip: ipInfo(webhookField(data, 'public_ip')), + local_ip: ipInfo(webhookField(data, 'local_ip')), + connection_type: webhookString(data, 'connection_type'), + os: webhookString(data, 'os'), + browser: webhookString(data, 'browser'), + device_type: webhookString(data, 'device_type'), traffic_source: traffic, - risk_score: normalizeScore(data, 'risk_score'), + risk_score: webhookScore(data, 'risk_score'), signals, detection_flags: flags, - observed_at: parseRfc3339(own(data, 'observed_at')), + observed_at: parseRfc3339(webhookField(data, 'observed_at')), source: 'webhook', raw: data, }; diff --git a/src/types.ts b/src/types.ts index 93266c4..4b037f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,14 @@ +import type { + HistoryPath, + HistoryResponse, + PingWebhook, + ProfileResponse, + ScoredData, + ScoredWebhook, +} from './wire.js'; + /** Identifier types the History API can search by. */ -export type LookupType = - 'ip' | 'user_hid' | 'visitor_id' | 'request_id' | 'device_id' | 'session_id' | 'cookie_id'; +export type LookupType = HistoryPath['search_type']; /** The three risk bands: trusted 0-29, suspicious 30-59, dangerous 60-100. */ export type RiskBand = 'trusted' | 'suspicious' | 'dangerous'; @@ -17,99 +25,58 @@ export type ConnectionType = | 'privacy_relay' | 'browser_vpn_proxy' | 'unknown' - | (string & {}); + | (ScoredData['connection_type'] & {}); + +type WireIpInfo = ScoredData['public_ip']; +type WireTrafficSource = ScoredData['traffic_source']; +type WireDetectionFlags = ScoredData['detection_flags']; /** An IP address with its country. */ -export interface IpInfo { - /** Dotted IPv4 address, or "" when none is known. */ - ip: string; - /** English country name (for example "Germany"), or "" when unknown. */ - country: string; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Preserve the augmentable public interface. +export interface IpInfo extends WireIpInfo {} /** Where the visit came from. Every value is a string, "" when absent. */ -export interface TrafficSource { - channel: string; - referrer_domain: string; - landing_url: string; - click_id_type: string; - utm_source: string; - utm_medium: string; - utm_campaign: string; - utm_content: string; - utm_term: string; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Preserve the augmentable public interface. +export interface TrafficSource extends WireTrafficSource {} + +type WireSignal = ScoredData['signals'][number]; /** One weighted risk signal behind the Risk Score. Display and log these; branch on detection_flags. */ -export interface IdentificationSignal { - /** Signal slug, for example "vpn" or "antidetect_browser". An open set: see SIGNALS for known values. */ - name: string; - /** Weight of the signal. Can be negative. Never sum weights yourself. */ - weight: number; +export interface IdentificationSignal extends WireSignal { /** Server description of the signal (History API rows only; null for webhooks). */ description: string | null; } /** The 19 stable detection flags. A flag the server did not send is false. */ -export interface DetectionFlags { - vpn: boolean; - privacy_relay: boolean; - browser_vpn_proxy: boolean; - tor: boolean; - proxy: boolean; - datacenter_ip: boolean; - abuser: boolean; - os_mismatch: boolean; - os_not_detected: boolean; - timezone_mismatch: boolean; - anti_detect_browser: boolean; - browser_automation: boolean; - ip_mismatch: boolean; - incognito: boolean; - search_bot: boolean; - suspicious_paid_click: boolean; - javascript_disabled: boolean; - stun_not_checked: boolean; - check_incomplete: boolean; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- Preserve the augmentable public interface. +export interface DetectionFlags extends WireDetectionFlags {} /** * One identification (one run of the ShieldLabs agent in one browser), normalized from a * webhook `data` object or a History API row. Property names follow the webhook JSON. */ -export interface Identification { - /** UUID of the identification, created in the browser. */ - request_id: string; - /** Server-side visitor identifier (sticky to the device). */ - visitor_id: string; - /** Server-side device identifier. The all-zero UUID means no usable device signals. */ - device_id: string; - /** One visit on one origin. */ - session_id: string; - /** First-party browser identifier kept by the agent. */ - cookie_id: string; - /** Your User HID as sent by the browser: "anonymous" for anonymous checks, null when it was empty. */ - user_hid: string | null; - /** Registered domain of the site. */ - domain: string; +export interface Identification extends Omit< + ScoredData, + | 'connection_type' + | 'signals' + | 'observed_at' + | 'public_ip' + | 'local_ip' + | 'traffic_source' + | 'detection_flags' +> { public_ip: IpInfo; local_ip: IpInfo; - connection_type: ConnectionType; - os: string; - browser: string; - /** "desktop", "mobile", "tablet" or "unknown". */ - device_type: string; traffic_source: TrafficSource; - /** Integer 0-100. A value above 100 (999) is a rate-limit marker, never a score. */ - risk_score: number; - signals: IdentificationSignal[]; detection_flags: DetectionFlags; + connection_type: ConnectionType; + signals: IdentificationSignal[]; /** * When the identification was observed, as an RFC 3339 UTC string with milliseconds * (for example "2026-09-30T12:34:56.123Z"). null only when the server sent a timestamp * that could not be parsed. */ - observed_at: string | null; + observed_at: ScoredData['observed_at'] | null; /** Which payload this identification was built from. */ source: 'webhook' | 'history'; /** The original webhook `data` object or History row, including fields this model omits. */ @@ -117,25 +84,23 @@ export interface Identification { } /** One page of History API results. */ -export interface HistoryPage { +export interface HistoryPage extends Omit { /** Identifications, newest first. */ data: Identification[]; - /** Total number of identifications that match the lookup. */ - total: number; } /** Domain profile from the Management API. */ export interface DomainProfile { /** The registered domain. */ - domain: string; + domain: ProfileResponse['Domain']; /** Remaining included identifications. Negative when the account is over its included volume. */ - remaining_identifications: number; + remaining_identifications: ProfileResponse['Weight']; /** Public Key with every character except the last 4 replaced by "*". */ - public_key_masked: string; + public_key_masked: ProfileResponse['PublicKey']; /** Secret Key with every character except the last 4 replaced by "*". */ - secret_key_masked: string; + secret_key_masked: ProfileResponse['Secret']; /** When the domain was created, as an RFC 3339 UTC string with milliseconds, or null. */ - created_at: string | null; + created_at: ProfileResponse['CreatedAt'] | null; /** The original response object. */ raw: Record; } @@ -157,21 +122,14 @@ declare enum UnknownEventTypeMarker { export type UnknownEventType = UnknownEventTypeMarker; /** A verified `identification.scored` delivery. */ -export interface IdentificationScoredEvent { - event_type: 'identification.scored'; - schema_version: string; - /** Envelope timestamp as sent (RFC 3339). */ - created_at: string; +export interface IdentificationScoredEvent extends Omit { data: Identification; /** The parsed envelope as received. */ raw: Record; } /** A verified `webhook.ping` delivery (sent by Verify in the analytics dashboard). */ -export interface WebhookPingEvent { - event_type: 'webhook.ping'; - schema_version: string; - created_at: string; +export interface WebhookPingEvent extends PingWebhook { raw: Record; } diff --git a/src/validation.ts b/src/validation.ts index 472dcd2..54ec930 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,6 +1,7 @@ import { LOOKUP_TYPES } from './constants.js'; import { ValidationError } from './errors.js'; import type { LookupType } from './types.js'; +import type { HistoryPath } from './wire.js'; const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; const IPV4_OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'; @@ -8,9 +9,9 @@ const IPV4 = new RegExp(`^${IPV4_OCTET}(?:\\.${IPV4_OCTET}){3}$`); export interface Lookup { type: LookupType; - value: string; + value: HistoryPath['value']; /** The value escaped as one URL path segment. */ - segment: string; + segment: HistoryPath['value']; } function describe(value: unknown): string { diff --git a/src/version.ts b/src/version.ts index e752df1..8a61532 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ /** Version of this SDK. Sent in the User-Agent header of every request. */ -export const VERSION = '1.0.1'; +export const VERSION = '1.0.2'; diff --git a/src/wire.ts b/src/wire.ts new file mode 100644 index 0000000..9b9d8e0 --- /dev/null +++ b/src/wire.ts @@ -0,0 +1,25 @@ +/** HTTP shapes generated from the checked-in OpenAPI description. Internal to the SDK. */ +import type { components, operations } from './generated/api.js'; + +export type HistoryPath = operations['searchHistory']['parameters']['path']; +export type HistoryQuery = NonNullable; +export type HistoryResponse = + operations['searchHistory']['responses'][200]['content']['application/json']; +export type HistoryRow = HistoryResponse['data'][number]; +export type ProfileHeaders = operations['getDomainProfile']['parameters']['header']; +export type ProfileResponse = + operations['getDomainProfile']['responses'][200]['content']['application/json']; +export type ScoredWebhook = + operations['identificationScored']['requestBody']['content']['application/json']; +export type PingWebhook = operations['webhookPing']['requestBody']['content']['application/json']; +export type ScoredData = ScoredWebhook['data']; +export type ScoreDetail = components['schemas']['ScoreDetail']; + +/** + * Declared fields of a wire object, optionally restricted to a value type. The History row's + * index signature allows future fields in raw data; it must not hide a renamed column here. + */ +export type WireKeys = keyof { + [K in keyof T as string extends K ? never : NonNullable extends Value ? K : never]: T[K]; +} & + string; diff --git a/test/consumer/usage.ts.txt b/test/consumer/usage.ts.txt new file mode 100644 index 0000000..8c41697 --- /dev/null +++ b/test/consumer/usage.ts.txt @@ -0,0 +1,67 @@ +import { + ShieldLabs, ShieldLabsManagement, webhooks, VERSION, + type ConnectionType, type DetectionFlags, type DomainProfile, type HistoryPage, + type Identification, type IdentificationSignal, type IpInfo, type LookupType, + type SearchOptions, type TrafficSource, type WebhookEvent, + type IdentificationScoredEvent, type WebhookPingEvent, type UnknownWebhookEvent, +} from '@shieldlabs-ai/node'; + +const client = new ShieldLabs({ apiKey: 'sec_example_private_key' }); +const lookup: LookupType = 'request_id'; +const options: SearchOptions = { limit: 20, offset: undefined, signal: undefined }; +const page: Promise = client.history.search(lookup, 'a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d', options); +const one: Promise = client.identifications.get('a5b7c9d1-e3f5-4a7b-9c1d-3e5f7a9b1c3d'); +const rows: AsyncIterable = client.history.iterate('user_hid', 'example'); +const profile: Promise = new ShieldLabsManagement({ secretKey: 'example_secret', domain: 'example.com' }).getProfile(); + +// @ts-expect-error Only generated lookup types can be sent. +client.history.search('email', 'example'); +// @ts-expect-error Page sizes are numbers. +client.history.search('user_hid', 'example', { limit: '20' }); +// @ts-expect-error Domain headers are strings. +new ShieldLabsManagement({ secretKey: 'example_secret', domain: 123 }); + +function consume(id: Identification, response: HistoryPage, domain: DomainProfile) { + const score: number = id.risk_score; + const flags: DetectionFlags = id.detection_flags; + const flag: boolean = flags.vpn; + const user: string | null = id.user_hid; + const observed: string | null = id.observed_at; + const source: 'history' | 'webhook' = id.source; + const raw: Record = id.raw; + const ip: IpInfo = id.public_ip; + const traffic: TrafficSource = id.traffic_source; + const signal: IdentificationSignal = { name: 'future_signal', weight: -30, description: null }; + const connection: ConnectionType = 'future_connection'; + const total: number = response.total; + const remaining: number = domain.remaining_identifications; + const created: string | null = domain.created_at; + const masked: string = domain.secret_key_masked; + traffic.channel = 'future_channel'; + // @ts-expect-error Normalized scores are numeric. + id.risk_score = '30'; + return { score, flag, user, observed, source, raw, ip, traffic, signal, connection, total, remaining, created, masked }; +} + +function consumeEvent(event: WebhookEvent) { + switch (event.event_type) { + case 'identification.scored': { + const scored: IdentificationScoredEvent = event; + const id: Identification = scored.data; + return id.risk_score; + } + case 'webhook.ping': { + const ping: WebhookPingEvent = event; + // @ts-expect-error Ping has no identification data. + ping.data; + return ping.created_at; + } + default: { + const unknown: UnknownWebhookEvent = event; + return unknown.raw; + } + } +} + +const verified: WebhookEvent = webhooks.constructEvent('{}', 'sha256=' + '0'.repeat(64), 'whsec_example'); +void [page, one, rows, profile, consume, consumeEvent, verified, VERSION]; diff --git a/test/entries.test.ts b/test/entries.test.ts index 799d4a3..5950d1c 100644 --- a/test/entries.test.ts +++ b/test/entries.test.ts @@ -65,7 +65,7 @@ describe('public exports', () => { version: string; }; expect(node.VERSION).toBe(pkg.version); - expect(node.VERSION).toBe('1.0.1'); + expect(node.VERSION).toBe('1.0.2'); }); it('expose frozen webhook helpers', () => { @@ -159,7 +159,7 @@ describe('User-Agent', () => { }); it('names the SDK, its version and the runtime', () => { - expect(userAgent()).toBe(`shieldlabs-node/1.0.1 node/${process.versions.node}`); + expect(userAgent()).toBe(`shieldlabs-node/1.0.2 node/${process.versions.node}`); }); it('detects other runtimes', () => { diff --git a/test/wire.test.ts b/test/wire.test.ts new file mode 100644 index 0000000..a1cca3d --- /dev/null +++ b/test/wire.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; +import type { LOOKUP_TYPES } from '../src/constants.js'; +import type { operations } from '../src/generated/api.js'; +import type { + DetectionFlags, + DomainProfile, + HistoryPage, + Identification, + IdentificationScoredEvent, + IdentificationSignal, + LookupType, + SearchOptions, + ShieldLabs, + ShieldLabsManagementOptions, + WebhookPingEvent, +} from '../src/index.js'; +import type { + HistoryPath, + HistoryQuery, + HistoryResponse, + HistoryRow, + ProfileResponse, + ScoredData, + WireKeys, +} from '../src/wire.js'; +import historyPage from './data/history-page.json'; +import profile from './data/management-profile.json'; +import scored from './data/webhook-identification-scored.json'; +import ping from './data/webhook-ping.json'; +import { fromHistoryRow, fromWebhookData } from '../src/normalize.js'; + +describe('generated wire types', () => { + it('drive the supported History parameters and Management header', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf<(typeof LOOKUP_TYPES)[number]>().toEqualTypeOf(); + expectTypeOf[1]>().toEqualTypeOf< + HistoryPath['value'] + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + operations['getDomainProfile']['parameters']['header']['X-Shield-Domain'] + >(); + }); + + it('derive response fields while retaining the normalized public models', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + ProfileResponse['Weight'] + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + ScoredData['signals'][number]['weight'] + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf< + IdentificationScoredEvent['event_type'] + >().toEqualTypeOf<'identification.scored'>(); + expectTypeOf().toEqualTypeOf<'webhook.ping'>(); + expectTypeOf>().toEqualTypeOf<'score' | 'ver'>(); + expectTypeOf<'future_field'>().not.toExtend>(); + }); + + it('accept recorded wire responses as input to the normalizers', () => { + const page: HistoryResponse = historyPage; + const domain: ProfileResponse = profile; + const data: ScoredData = scored.data; + expect(page.data.map(fromHistoryRow)).toHaveLength(historyPage.data.length); + expect(domain.Weight).toBe(profile.Weight); + expect(fromWebhookData(data).risk_score).toBe(data.risk_score); + // JSON imports widen string literals, so narrow only the two event discriminants. + const event = { + ...scored, + event_type: 'identification.scored' as const, + } satisfies operations['identificationScored']['requestBody']['content']['application/json']; + const pingEvent = { + ...ping, + event_type: 'webhook.ping' as const, + } satisfies operations['webhookPing']['requestBody']['content']['application/json']; + expect(event.data).toBe(scored.data); + expect(pingEvent.event_type).toBe('webhook.ping'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 8a12b5e..c1c05ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,6 +19,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src", "test", "vitest.config.ts", "tsup.config.ts"], - "exclude": ["src/generated"] + "include": ["src", "test", "vitest.config.ts", "tsup.config.ts"] }