Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
src/generated/api.ts text eol=lf
resources/shieldlabs-api.yaml text eol=lf
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
27 changes: 22 additions & 5 deletions generate.mjs
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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}`);
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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. <contact@shieldlabs.ai>",
Expand Down Expand Up @@ -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": {
Expand Down
138 changes: 138 additions & 0 deletions scripts/check-consumer.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
8 changes: 8 additions & 0 deletions scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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)["']/;
Expand Down
Loading
Loading