@nightmoose/contractgate-sdk 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -0
- package/dist/client.d.ts +65 -0
- package/dist/client.js +180 -0
- package/dist/contract.d.ts +59 -0
- package/dist/contract.js +244 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.js +63 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/types.d.ts +108 -0
- package/dist/types.js +155 -0
- package/dist/validator.d.ts +4 -0
- package/dist/validator.js +229 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# @nightmoose/contractgate-sdk
|
|
2
|
+
|
|
3
|
+
Official TypeScript SDK for [ContractGate](https://contractgate.io) — HTTP client + local validator.
|
|
4
|
+
|
|
5
|
+
**Node 20+, ESM only.**
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @nightmoose/contractgate-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { Client, Contract } from "@nightmoose/contractgate-sdk";
|
|
17
|
+
|
|
18
|
+
// HTTP client
|
|
19
|
+
const c = new Client({ baseUrl: "https://gw.example.com", apiKey: process.env.CG_KEY });
|
|
20
|
+
|
|
21
|
+
const result = await c.ingest({ contractId: "...", events: [{ user_id: "alice_01", event_type: "click", timestamp: 1700000000 }] });
|
|
22
|
+
for (const r of result.results) {
|
|
23
|
+
if (!r.passed) for (const v of r.violations) console.log(v.field, v.kind, v.message);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Local validator (no network)
|
|
27
|
+
const contract = Contract.fromYaml(yamlString);
|
|
28
|
+
const compiled = contract.compile();
|
|
29
|
+
const vr = compiled.validate({ user_id: "alice_01", event_type: "click", timestamp: 1700000000 });
|
|
30
|
+
console.assert(vr.passed, vr.violations);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Client API
|
|
34
|
+
|
|
35
|
+
| Method | Description |
|
|
36
|
+
|--------|-------------|
|
|
37
|
+
| `ingest({ contractId, events, version?, dryRun?, atomic? })` | Validate + persist a batch |
|
|
38
|
+
| `egress({ contractId, events, version?, disposition?, dryRun? })` | Validate outbound payload |
|
|
39
|
+
| `audit({ contractId?, limit?, offset? })` | Read audit entries |
|
|
40
|
+
| `stats()` | Global ingestion stats |
|
|
41
|
+
| `getContract(id)` | Fetch contract metadata |
|
|
42
|
+
| `listContracts()` | List all contracts |
|
|
43
|
+
| `listVersions(contractId)` | List versions |
|
|
44
|
+
| `getVersion(contractId, version)` | Fetch a specific version |
|
|
45
|
+
| `getLatestStable(contractId)` | Fetch the latest stable version |
|
|
46
|
+
| `playgroundValidate({ yamlContent, event })` | Validate without persisting |
|
|
47
|
+
|
|
48
|
+
## Local validator
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { Contract } from "@nightmoose/contractgate-sdk";
|
|
52
|
+
|
|
53
|
+
const contract = Contract.fromYaml(`
|
|
54
|
+
version: "1.0"
|
|
55
|
+
name: "events"
|
|
56
|
+
ontology:
|
|
57
|
+
entities:
|
|
58
|
+
- name: user_id
|
|
59
|
+
type: string
|
|
60
|
+
required: true
|
|
61
|
+
pattern: "^[a-zA-Z0-9_-]+$"
|
|
62
|
+
- name: timestamp
|
|
63
|
+
type: integer
|
|
64
|
+
required: true
|
|
65
|
+
min: 0
|
|
66
|
+
`);
|
|
67
|
+
|
|
68
|
+
const compiled = contract.compile();
|
|
69
|
+
const { passed, violations } = compiled.validate({ user_id: "alice", timestamp: 1700000000 });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The local validator mirrors the Rust engine exactly. Conformance is enforced via a shared fixture corpus under `tests/conformance/`.
|
|
73
|
+
|
|
74
|
+
## Error hierarchy
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
ContractGateError
|
|
78
|
+
├── ContractCompileError — bad contract YAML
|
|
79
|
+
├── ConnectionError — network / DNS failure
|
|
80
|
+
└── HTTPError
|
|
81
|
+
├── BadRequestError — 400
|
|
82
|
+
├── AuthError — 401
|
|
83
|
+
├── NotFoundError — 404
|
|
84
|
+
├── ConflictError — 409
|
|
85
|
+
├── ValidationFailedError — 422 (whole-batch rejection)
|
|
86
|
+
└── ServerError — 5xx
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Per-event validation failures in a 207 Multi-Status response do **not** raise. They surface in `result.results[n].violations`.
|
|
90
|
+
|
|
91
|
+
## Version policy
|
|
92
|
+
|
|
93
|
+
SDK version is kept in lockstep with the ContractGate gateway minor version. `0.1.x` of the SDK works with gateway `0.1.x`.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AuditEntry, BatchIngestResponse, ContractResponse, EgressResponse, IngestionStats, VersionResponse, VersionSummary } from './types.js';
|
|
2
|
+
export interface ClientOptions {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
apiKey?: string;
|
|
5
|
+
orgId?: string;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
/** Override the global fetch (useful for tests). */
|
|
8
|
+
fetch?: typeof globalThis.fetch;
|
|
9
|
+
}
|
|
10
|
+
export declare class Client {
|
|
11
|
+
private readonly baseUrl;
|
|
12
|
+
private readonly apiKey?;
|
|
13
|
+
private readonly orgId?;
|
|
14
|
+
private readonly timeoutMs;
|
|
15
|
+
private readonly fetchFn;
|
|
16
|
+
constructor(opts: ClientOptions);
|
|
17
|
+
ingest(opts: {
|
|
18
|
+
contractId: string;
|
|
19
|
+
events: unknown;
|
|
20
|
+
version?: string;
|
|
21
|
+
dryRun?: boolean;
|
|
22
|
+
atomic?: boolean;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}): Promise<BatchIngestResponse>;
|
|
25
|
+
egress(opts: {
|
|
26
|
+
contractId: string;
|
|
27
|
+
events: unknown;
|
|
28
|
+
version?: string;
|
|
29
|
+
disposition?: string;
|
|
30
|
+
dryRun?: boolean;
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
}): Promise<EgressResponse>;
|
|
33
|
+
audit(opts?: {
|
|
34
|
+
contractId?: string;
|
|
35
|
+
limit?: number;
|
|
36
|
+
offset?: number;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}): Promise<AuditEntry[]>;
|
|
39
|
+
stats(opts?: {
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}): Promise<IngestionStats>;
|
|
42
|
+
getContract(contractId: string, opts?: {
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
}): Promise<ContractResponse>;
|
|
45
|
+
listContracts(opts?: {
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
}): Promise<ContractResponse[]>;
|
|
48
|
+
listVersions(contractId: string, opts?: {
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
}): Promise<VersionSummary[]>;
|
|
51
|
+
getVersion(contractId: string, version: string, opts?: {
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
}): Promise<VersionResponse>;
|
|
54
|
+
getLatestStable(contractId: string, opts?: {
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
}): Promise<VersionResponse>;
|
|
57
|
+
playgroundValidate(opts: {
|
|
58
|
+
yamlContent: string;
|
|
59
|
+
event: unknown;
|
|
60
|
+
timeoutMs?: number;
|
|
61
|
+
}): Promise<unknown>;
|
|
62
|
+
private buildUrl;
|
|
63
|
+
private buildHeaders;
|
|
64
|
+
private request;
|
|
65
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { ConnectionError, raiseForStatus } from './errors.js';
|
|
2
|
+
import { auditEntryFromJson, batchIngestResponseFromJson, contractResponseFromJson, egressResponseFromJson, expectList, ingestionStatsFromJson, versionResponseFromJson, versionSummaryFromJson, } from './types.js';
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
4
|
+
const SDK_VERSION = '0.2.0';
|
|
5
|
+
const USER_AGENT = `contractgate-typescript/${SDK_VERSION}`;
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Client
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
export class Client {
|
|
10
|
+
baseUrl;
|
|
11
|
+
apiKey;
|
|
12
|
+
orgId;
|
|
13
|
+
timeoutMs;
|
|
14
|
+
fetchFn;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
|
|
17
|
+
this.apiKey = opts.apiKey;
|
|
18
|
+
this.orgId = opts.orgId;
|
|
19
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
20
|
+
this.fetchFn = opts.fetch ?? globalThis.fetch;
|
|
21
|
+
}
|
|
22
|
+
// ── Ingest ────────────────────────────────────────────────────────────────
|
|
23
|
+
async ingest(opts) {
|
|
24
|
+
const params = {};
|
|
25
|
+
if (opts.dryRun)
|
|
26
|
+
params['dry_run'] = 'true';
|
|
27
|
+
if (opts.atomic)
|
|
28
|
+
params['atomic'] = 'true';
|
|
29
|
+
const extra = {};
|
|
30
|
+
if (opts.version)
|
|
31
|
+
extra['X-Contract-Version'] = opts.version;
|
|
32
|
+
const body = await this.request({
|
|
33
|
+
method: 'POST',
|
|
34
|
+
path: `/ingest/${opts.contractId}`,
|
|
35
|
+
params,
|
|
36
|
+
body: opts.events,
|
|
37
|
+
extraHeaders: extra,
|
|
38
|
+
timeoutMs: opts.timeoutMs,
|
|
39
|
+
});
|
|
40
|
+
return batchIngestResponseFromJson(body);
|
|
41
|
+
}
|
|
42
|
+
// ── Egress ────────────────────────────────────────────────────────────────
|
|
43
|
+
async egress(opts) {
|
|
44
|
+
const params = {};
|
|
45
|
+
if (opts.dryRun)
|
|
46
|
+
params['dry_run'] = 'true';
|
|
47
|
+
if (opts.disposition)
|
|
48
|
+
params['disposition'] = opts.disposition;
|
|
49
|
+
const extra = {};
|
|
50
|
+
if (opts.version)
|
|
51
|
+
extra['X-Contract-Version'] = opts.version;
|
|
52
|
+
const body = await this.request({
|
|
53
|
+
method: 'POST',
|
|
54
|
+
path: `/egress/${opts.contractId}`,
|
|
55
|
+
params,
|
|
56
|
+
body: opts.events,
|
|
57
|
+
extraHeaders: extra,
|
|
58
|
+
timeoutMs: opts.timeoutMs,
|
|
59
|
+
});
|
|
60
|
+
return egressResponseFromJson(body);
|
|
61
|
+
}
|
|
62
|
+
// ── Audit ─────────────────────────────────────────────────────────────────
|
|
63
|
+
async audit(opts = {}) {
|
|
64
|
+
const params = {
|
|
65
|
+
limit: String(opts.limit ?? 50),
|
|
66
|
+
offset: String(opts.offset ?? 0),
|
|
67
|
+
};
|
|
68
|
+
if (opts.contractId)
|
|
69
|
+
params['contract_id'] = opts.contractId;
|
|
70
|
+
const body = await this.request({ method: 'GET', path: '/audit', params, timeoutMs: opts.timeoutMs });
|
|
71
|
+
return expectList(body).map(auditEntryFromJson);
|
|
72
|
+
}
|
|
73
|
+
async stats(opts = {}) {
|
|
74
|
+
const body = await this.request({ method: 'GET', path: '/stats', timeoutMs: opts.timeoutMs });
|
|
75
|
+
return ingestionStatsFromJson(body);
|
|
76
|
+
}
|
|
77
|
+
// ── Contract reads ────────────────────────────────────────────────────────
|
|
78
|
+
async getContract(contractId, opts = {}) {
|
|
79
|
+
const body = await this.request({
|
|
80
|
+
method: 'GET',
|
|
81
|
+
path: `/contracts/${contractId}`,
|
|
82
|
+
timeoutMs: opts.timeoutMs,
|
|
83
|
+
});
|
|
84
|
+
return contractResponseFromJson(body);
|
|
85
|
+
}
|
|
86
|
+
async listContracts(opts = {}) {
|
|
87
|
+
const body = await this.request({ method: 'GET', path: '/contracts', timeoutMs: opts.timeoutMs });
|
|
88
|
+
return expectList(body).map(contractResponseFromJson);
|
|
89
|
+
}
|
|
90
|
+
async listVersions(contractId, opts = {}) {
|
|
91
|
+
const body = await this.request({
|
|
92
|
+
method: 'GET',
|
|
93
|
+
path: `/contracts/${contractId}/versions`,
|
|
94
|
+
timeoutMs: opts.timeoutMs,
|
|
95
|
+
});
|
|
96
|
+
return expectList(body).map(versionSummaryFromJson);
|
|
97
|
+
}
|
|
98
|
+
async getVersion(contractId, version, opts = {}) {
|
|
99
|
+
const body = await this.request({
|
|
100
|
+
method: 'GET',
|
|
101
|
+
path: `/contracts/${contractId}/versions/${version}`,
|
|
102
|
+
timeoutMs: opts.timeoutMs,
|
|
103
|
+
});
|
|
104
|
+
return versionResponseFromJson(body);
|
|
105
|
+
}
|
|
106
|
+
async getLatestStable(contractId, opts = {}) {
|
|
107
|
+
const body = await this.request({
|
|
108
|
+
method: 'GET',
|
|
109
|
+
path: `/contracts/${contractId}/versions/latest-stable`,
|
|
110
|
+
timeoutMs: opts.timeoutMs,
|
|
111
|
+
});
|
|
112
|
+
return versionResponseFromJson(body);
|
|
113
|
+
}
|
|
114
|
+
// ── Playground ────────────────────────────────────────────────────────────
|
|
115
|
+
async playgroundValidate(opts) {
|
|
116
|
+
return this.request({
|
|
117
|
+
method: 'POST',
|
|
118
|
+
path: '/playground/validate',
|
|
119
|
+
body: { yaml_content: opts.yamlContent, event: opts.event },
|
|
120
|
+
timeoutMs: opts.timeoutMs,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
124
|
+
buildUrl(path, params) {
|
|
125
|
+
const url = new URL(`${this.baseUrl}${path}`);
|
|
126
|
+
if (params) {
|
|
127
|
+
for (const [k, v] of Object.entries(params)) {
|
|
128
|
+
url.searchParams.set(k, v);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return url.toString();
|
|
132
|
+
}
|
|
133
|
+
buildHeaders(extra) {
|
|
134
|
+
const h = {
|
|
135
|
+
'User-Agent': USER_AGENT,
|
|
136
|
+
'Accept': 'application/json',
|
|
137
|
+
};
|
|
138
|
+
if (this.apiKey)
|
|
139
|
+
h['x-api-key'] = this.apiKey;
|
|
140
|
+
if (this.orgId)
|
|
141
|
+
h['x-org-id'] = this.orgId;
|
|
142
|
+
if (extra)
|
|
143
|
+
Object.assign(h, extra);
|
|
144
|
+
return h;
|
|
145
|
+
}
|
|
146
|
+
async request(opts) {
|
|
147
|
+
const url = this.buildUrl(opts.path, opts.params);
|
|
148
|
+
const headers = this.buildHeaders(opts.extraHeaders);
|
|
149
|
+
let bodyStr;
|
|
150
|
+
if (opts.body !== undefined) {
|
|
151
|
+
headers['Content-Type'] = 'application/json';
|
|
152
|
+
bodyStr = JSON.stringify(opts.body);
|
|
153
|
+
}
|
|
154
|
+
const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
|
|
155
|
+
let response;
|
|
156
|
+
try {
|
|
157
|
+
response = await this.fetchFn(url, {
|
|
158
|
+
method: opts.method,
|
|
159
|
+
headers,
|
|
160
|
+
body: bodyStr,
|
|
161
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
throw new ConnectionError(String(e));
|
|
166
|
+
}
|
|
167
|
+
const text = await response.text();
|
|
168
|
+
let parsed = null;
|
|
169
|
+
if (text) {
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(text);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
parsed = text;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
raiseForStatus(response.status, parsed);
|
|
178
|
+
return parsed;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ValidationResult } from './types.js';
|
|
2
|
+
export type FieldType = 'string' | 'integer' | 'float' | 'boolean' | 'object' | 'array' | 'any';
|
|
3
|
+
export declare function fieldTypeDisplay(ft: FieldType): string;
|
|
4
|
+
export type TransformKind = 'mask' | 'hash' | 'drop' | 'redact';
|
|
5
|
+
export type MaskStyle = 'opaque' | 'format_preserving';
|
|
6
|
+
export interface Transform {
|
|
7
|
+
kind: TransformKind;
|
|
8
|
+
style?: MaskStyle;
|
|
9
|
+
}
|
|
10
|
+
export interface FieldDefinition {
|
|
11
|
+
name: string;
|
|
12
|
+
fieldType: FieldType;
|
|
13
|
+
required: boolean;
|
|
14
|
+
pattern?: string;
|
|
15
|
+
allowedValues?: unknown[];
|
|
16
|
+
min?: number;
|
|
17
|
+
max?: number;
|
|
18
|
+
minLength?: number;
|
|
19
|
+
maxLength?: number;
|
|
20
|
+
properties?: FieldDefinition[];
|
|
21
|
+
items?: FieldDefinition;
|
|
22
|
+
transform?: Transform;
|
|
23
|
+
}
|
|
24
|
+
export type MetricType = 'integer' | 'float';
|
|
25
|
+
export interface MetricDefinition {
|
|
26
|
+
name: string;
|
|
27
|
+
field?: string;
|
|
28
|
+
metricType?: MetricType;
|
|
29
|
+
formula?: string;
|
|
30
|
+
min?: number;
|
|
31
|
+
max?: number;
|
|
32
|
+
}
|
|
33
|
+
export interface GlossaryEntry {
|
|
34
|
+
field: string;
|
|
35
|
+
description: string;
|
|
36
|
+
constraints?: string;
|
|
37
|
+
synonyms?: string[];
|
|
38
|
+
}
|
|
39
|
+
export interface ContractData {
|
|
40
|
+
version: string;
|
|
41
|
+
name: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
complianceMode: boolean;
|
|
44
|
+
entities: FieldDefinition[];
|
|
45
|
+
glossary: GlossaryEntry[];
|
|
46
|
+
metrics: MetricDefinition[];
|
|
47
|
+
}
|
|
48
|
+
export interface CompiledContract {
|
|
49
|
+
contract: ContractData;
|
|
50
|
+
patterns: Map<string, RegExp>;
|
|
51
|
+
declaredTopLevelFields: Set<string>;
|
|
52
|
+
validate(event: unknown): ValidationResult;
|
|
53
|
+
}
|
|
54
|
+
export declare class Contract {
|
|
55
|
+
private readonly data;
|
|
56
|
+
private constructor();
|
|
57
|
+
static fromYaml(source: string): Contract;
|
|
58
|
+
compile(): CompiledContract;
|
|
59
|
+
}
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import yaml from 'js-yaml';
|
|
2
|
+
import { ContractCompileError } from './errors.js';
|
|
3
|
+
// validate is imported at runtime; validator.ts uses only "import type" from this
|
|
4
|
+
// module so there is no cycle in the compiled JavaScript.
|
|
5
|
+
import { validate as runValidate } from './validator.js';
|
|
6
|
+
const FIELD_TYPE_DISPLAY = {
|
|
7
|
+
string: 'String',
|
|
8
|
+
integer: 'Integer',
|
|
9
|
+
float: 'Float',
|
|
10
|
+
boolean: 'Boolean',
|
|
11
|
+
object: 'Object',
|
|
12
|
+
array: 'Array',
|
|
13
|
+
any: 'Any',
|
|
14
|
+
};
|
|
15
|
+
export function fieldTypeDisplay(ft) {
|
|
16
|
+
return FIELD_TYPE_DISPLAY[ft];
|
|
17
|
+
}
|
|
18
|
+
function parseFieldType(raw) {
|
|
19
|
+
if (typeof raw !== 'string') {
|
|
20
|
+
throw new ContractCompileError(`field type must be a string, got ${typeof raw}`);
|
|
21
|
+
}
|
|
22
|
+
const normalized = raw.toLowerCase() === 'number' ? 'float' : raw.toLowerCase();
|
|
23
|
+
const valid = ['string', 'integer', 'float', 'boolean', 'object', 'array', 'any'];
|
|
24
|
+
if (!valid.includes(normalized)) {
|
|
25
|
+
throw new ContractCompileError(`unknown field type: ${JSON.stringify(raw)}`);
|
|
26
|
+
}
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
function parseTransform(raw) {
|
|
30
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
31
|
+
throw new ContractCompileError('transform must be a mapping');
|
|
32
|
+
}
|
|
33
|
+
const r = raw;
|
|
34
|
+
const kindRaw = r['kind'];
|
|
35
|
+
if (typeof kindRaw !== 'string') {
|
|
36
|
+
throw new ContractCompileError('transform.kind is required and must be a string');
|
|
37
|
+
}
|
|
38
|
+
const validKinds = ['mask', 'hash', 'drop', 'redact'];
|
|
39
|
+
const kind = kindRaw.toLowerCase();
|
|
40
|
+
if (!validKinds.includes(kind)) {
|
|
41
|
+
throw new ContractCompileError(`unknown transform kind: ${JSON.stringify(kindRaw)}`);
|
|
42
|
+
}
|
|
43
|
+
let style;
|
|
44
|
+
if (r['style'] != null) {
|
|
45
|
+
const validStyles = ['opaque', 'format_preserving'];
|
|
46
|
+
const s = String(r['style']).toLowerCase();
|
|
47
|
+
if (!validStyles.includes(s)) {
|
|
48
|
+
throw new ContractCompileError(`unknown mask style: ${JSON.stringify(r['style'])}`);
|
|
49
|
+
}
|
|
50
|
+
style = s;
|
|
51
|
+
}
|
|
52
|
+
return { kind, style };
|
|
53
|
+
}
|
|
54
|
+
function parseFieldDefinition(raw) {
|
|
55
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
56
|
+
throw new ContractCompileError(`entity must be a mapping, got ${typeof raw}`);
|
|
57
|
+
}
|
|
58
|
+
const r = raw;
|
|
59
|
+
const name = r['name'];
|
|
60
|
+
if (typeof name !== 'string' || !name) {
|
|
61
|
+
throw new ContractCompileError('entity.name is required');
|
|
62
|
+
}
|
|
63
|
+
const fieldType = parseFieldType(r['type']);
|
|
64
|
+
let properties;
|
|
65
|
+
if (r['properties'] != null) {
|
|
66
|
+
if (!Array.isArray(r['properties'])) {
|
|
67
|
+
throw new ContractCompileError(`${name}.properties must be a list`);
|
|
68
|
+
}
|
|
69
|
+
properties = r['properties'].map(parseFieldDefinition);
|
|
70
|
+
}
|
|
71
|
+
let items;
|
|
72
|
+
if (r['items'] != null) {
|
|
73
|
+
items = parseFieldDefinition(r['items']);
|
|
74
|
+
}
|
|
75
|
+
let transform;
|
|
76
|
+
if (r['transform'] != null) {
|
|
77
|
+
transform = parseTransform(r['transform']);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
fieldType,
|
|
82
|
+
required: r['required'] !== false,
|
|
83
|
+
pattern: typeof r['pattern'] === 'string' ? r['pattern'] : undefined,
|
|
84
|
+
allowedValues: Array.isArray(r['enum']) ? r['enum'] : undefined,
|
|
85
|
+
min: typeof r['min'] === 'number' ? r['min'] : undefined,
|
|
86
|
+
max: typeof r['max'] === 'number' ? r['max'] : undefined,
|
|
87
|
+
minLength: typeof r['min_length'] === 'number' ? Math.trunc(r['min_length']) : undefined,
|
|
88
|
+
maxLength: typeof r['max_length'] === 'number' ? Math.trunc(r['max_length']) : undefined,
|
|
89
|
+
properties,
|
|
90
|
+
items,
|
|
91
|
+
transform,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function parseMetricDefinition(raw) {
|
|
95
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
96
|
+
throw new ContractCompileError('metric must be a mapping');
|
|
97
|
+
}
|
|
98
|
+
const r = raw;
|
|
99
|
+
const name = r['name'];
|
|
100
|
+
if (typeof name !== 'string' || !name) {
|
|
101
|
+
throw new ContractCompileError('metric.name is required');
|
|
102
|
+
}
|
|
103
|
+
let metricType;
|
|
104
|
+
if (r['type'] != null) {
|
|
105
|
+
const mt = String(r['type']).toLowerCase();
|
|
106
|
+
if (mt !== 'integer' && mt !== 'float') {
|
|
107
|
+
throw new ContractCompileError(`unknown metric type: ${JSON.stringify(r['type'])}`);
|
|
108
|
+
}
|
|
109
|
+
metricType = mt;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
name,
|
|
113
|
+
field: typeof r['field'] === 'string' ? r['field'] : undefined,
|
|
114
|
+
metricType,
|
|
115
|
+
formula: typeof r['formula'] === 'string' ? r['formula'] : undefined,
|
|
116
|
+
min: typeof r['min'] === 'number' ? r['min'] : undefined,
|
|
117
|
+
max: typeof r['max'] === 'number' ? r['max'] : undefined,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function parseGlossaryEntry(raw) {
|
|
121
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
122
|
+
throw new ContractCompileError('glossary entry must be a mapping');
|
|
123
|
+
}
|
|
124
|
+
const r = raw;
|
|
125
|
+
const fieldName = r['field'] ?? r['term'];
|
|
126
|
+
const description = r['description'] ?? r['definition'];
|
|
127
|
+
if (typeof fieldName !== 'string' || !fieldName) {
|
|
128
|
+
throw new ContractCompileError('glossary.field (or .term) is required');
|
|
129
|
+
}
|
|
130
|
+
if (typeof description !== 'string' || !description) {
|
|
131
|
+
throw new ContractCompileError('glossary.description (or .definition) is required');
|
|
132
|
+
}
|
|
133
|
+
let synonyms;
|
|
134
|
+
if (r['synonyms'] != null) {
|
|
135
|
+
if (!Array.isArray(r['synonyms'])) {
|
|
136
|
+
throw new ContractCompileError('glossary.synonyms must be a list');
|
|
137
|
+
}
|
|
138
|
+
synonyms = r['synonyms'].map(String);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
field: fieldName,
|
|
142
|
+
description,
|
|
143
|
+
constraints: typeof r['constraints'] === 'string' ? r['constraints'] : undefined,
|
|
144
|
+
synonyms,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export class Contract {
|
|
148
|
+
data;
|
|
149
|
+
constructor(data) {
|
|
150
|
+
this.data = data;
|
|
151
|
+
}
|
|
152
|
+
static fromYaml(source) {
|
|
153
|
+
let raw;
|
|
154
|
+
try {
|
|
155
|
+
raw = yaml.load(source);
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
throw new ContractCompileError(`invalid YAML: ${e}`);
|
|
159
|
+
}
|
|
160
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
161
|
+
throw new ContractCompileError('contract YAML must be a mapping at the top level');
|
|
162
|
+
}
|
|
163
|
+
const r = raw;
|
|
164
|
+
const version = r['version'];
|
|
165
|
+
const name = r['name'];
|
|
166
|
+
if (typeof version !== 'string' || !version) {
|
|
167
|
+
throw new ContractCompileError('contract.version is required');
|
|
168
|
+
}
|
|
169
|
+
if (typeof name !== 'string' || !name) {
|
|
170
|
+
throw new ContractCompileError('contract.name is required');
|
|
171
|
+
}
|
|
172
|
+
const ontologyRaw = r['ontology'];
|
|
173
|
+
if (ontologyRaw === null || typeof ontologyRaw !== 'object' || Array.isArray(ontologyRaw)) {
|
|
174
|
+
throw new ContractCompileError('contract.ontology is required and must be a mapping');
|
|
175
|
+
}
|
|
176
|
+
const entitiesRaw = ontologyRaw['entities'];
|
|
177
|
+
if (!Array.isArray(entitiesRaw)) {
|
|
178
|
+
throw new ContractCompileError('contract.ontology.entities must be a list');
|
|
179
|
+
}
|
|
180
|
+
const entities = entitiesRaw.map(parseFieldDefinition);
|
|
181
|
+
const glossary = Array.isArray(r['glossary'])
|
|
182
|
+
? r['glossary'].map(parseGlossaryEntry)
|
|
183
|
+
: [];
|
|
184
|
+
const metrics = Array.isArray(r['metrics'])
|
|
185
|
+
? r['metrics'].map(parseMetricDefinition)
|
|
186
|
+
: [];
|
|
187
|
+
return new Contract({
|
|
188
|
+
version,
|
|
189
|
+
name,
|
|
190
|
+
description: typeof r['description'] === 'string' ? r['description'] : undefined,
|
|
191
|
+
complianceMode: r['compliance_mode'] === true,
|
|
192
|
+
entities,
|
|
193
|
+
glossary,
|
|
194
|
+
metrics,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
compile() {
|
|
198
|
+
const patterns = new Map();
|
|
199
|
+
compileFieldPatterns(this.data.entities, '', patterns);
|
|
200
|
+
validateTransformTypes(this.data.entities, '');
|
|
201
|
+
const declaredTopLevelFields = this.data.complianceMode
|
|
202
|
+
? new Set(this.data.entities.map((e) => e.name))
|
|
203
|
+
: new Set();
|
|
204
|
+
const compiled = {
|
|
205
|
+
contract: this.data,
|
|
206
|
+
patterns,
|
|
207
|
+
declaredTopLevelFields,
|
|
208
|
+
validate(event) {
|
|
209
|
+
return runValidate(compiled, event);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
return compiled;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Compile helpers
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
function compileFieldPatterns(fields, prefix, out) {
|
|
219
|
+
for (const f of fields) {
|
|
220
|
+
const path = prefix ? `${prefix}.${f.name}` : f.name;
|
|
221
|
+
if (f.pattern != null) {
|
|
222
|
+
try {
|
|
223
|
+
out.set(path, new RegExp(f.pattern));
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
throw new ContractCompileError(`Invalid regex ${JSON.stringify(f.pattern)} for field ${JSON.stringify(path)}: ${e}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (f.fieldType === 'object' && f.properties) {
|
|
230
|
+
compileFieldPatterns(f.properties, path, out);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function validateTransformTypes(fields, prefix) {
|
|
235
|
+
for (const f of fields) {
|
|
236
|
+
const path = prefix ? `${prefix}.${f.name}` : f.name;
|
|
237
|
+
if (f.transform != null && f.fieldType !== 'string') {
|
|
238
|
+
throw new ContractCompileError(`Field '${path}' declares a PII transform but has type '${fieldTypeDisplay(f.fieldType)}' — transforms are only supported on string fields. If this field holds PII, change its type to 'string'.`);
|
|
239
|
+
}
|
|
240
|
+
if (f.fieldType === 'object' && f.properties) {
|
|
241
|
+
validateTransformTypes(f.properties, path);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare class ContractGateError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export declare class ContractCompileError extends ContractGateError {
|
|
5
|
+
}
|
|
6
|
+
export declare class ConnectionError extends ContractGateError {
|
|
7
|
+
}
|
|
8
|
+
export declare class HTTPError extends ContractGateError {
|
|
9
|
+
readonly status: number;
|
|
10
|
+
readonly body: unknown;
|
|
11
|
+
constructor(message: string, status: number, body: unknown);
|
|
12
|
+
}
|
|
13
|
+
export declare class BadRequestError extends HTTPError {
|
|
14
|
+
}
|
|
15
|
+
export declare class AuthError extends HTTPError {
|
|
16
|
+
}
|
|
17
|
+
export declare class NotFoundError extends HTTPError {
|
|
18
|
+
}
|
|
19
|
+
export declare class ConflictError extends HTTPError {
|
|
20
|
+
}
|
|
21
|
+
export declare class ValidationFailedError extends HTTPError {
|
|
22
|
+
}
|
|
23
|
+
export declare class ServerError extends HTTPError {
|
|
24
|
+
}
|
|
25
|
+
type HTTPErrorCtor = new (message: string, status: number, body: unknown) => HTTPError;
|
|
26
|
+
export declare function statusToException(status: number): HTTPErrorCtor;
|
|
27
|
+
export declare function raiseForStatus(status: number, body: unknown): void;
|
|
28
|
+
export {};
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export class ContractGateError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = this.constructor.name;
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class ContractCompileError extends ContractGateError {
|
|
8
|
+
}
|
|
9
|
+
export class ConnectionError extends ContractGateError {
|
|
10
|
+
}
|
|
11
|
+
export class HTTPError extends ContractGateError {
|
|
12
|
+
status;
|
|
13
|
+
body;
|
|
14
|
+
constructor(message, status, body) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.body = body;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class BadRequestError extends HTTPError {
|
|
21
|
+
}
|
|
22
|
+
export class AuthError extends HTTPError {
|
|
23
|
+
}
|
|
24
|
+
export class NotFoundError extends HTTPError {
|
|
25
|
+
}
|
|
26
|
+
export class ConflictError extends HTTPError {
|
|
27
|
+
}
|
|
28
|
+
export class ValidationFailedError extends HTTPError {
|
|
29
|
+
}
|
|
30
|
+
export class ServerError extends HTTPError {
|
|
31
|
+
}
|
|
32
|
+
export function statusToException(status) {
|
|
33
|
+
if (status === 400)
|
|
34
|
+
return BadRequestError;
|
|
35
|
+
if (status === 401)
|
|
36
|
+
return AuthError;
|
|
37
|
+
if (status === 404)
|
|
38
|
+
return NotFoundError;
|
|
39
|
+
if (status === 409)
|
|
40
|
+
return ConflictError;
|
|
41
|
+
if (status === 422)
|
|
42
|
+
return ValidationFailedError;
|
|
43
|
+
if (status >= 500 && status < 600)
|
|
44
|
+
return ServerError;
|
|
45
|
+
return HTTPError;
|
|
46
|
+
}
|
|
47
|
+
export function raiseForStatus(status, body) {
|
|
48
|
+
if (status >= 200 && status < 300)
|
|
49
|
+
return;
|
|
50
|
+
const Cls = statusToException(status);
|
|
51
|
+
const message = extractErrorMessage(body) ?? `HTTP ${status}`;
|
|
52
|
+
throw new Cls(message, status, body);
|
|
53
|
+
}
|
|
54
|
+
function extractErrorMessage(body) {
|
|
55
|
+
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
56
|
+
const b = body;
|
|
57
|
+
for (const key of ['error', 'message', 'detail']) {
|
|
58
|
+
if (typeof b[key] === 'string')
|
|
59
|
+
return b[key];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { Client } from './client.js';
|
|
2
|
+
export type { ClientOptions } from './client.js';
|
|
3
|
+
export { Contract } from './contract.js';
|
|
4
|
+
export type { CompiledContract, ContractData, FieldDefinition, FieldType, GlossaryEntry, MaskStyle, MetricDefinition, MetricType, Transform, TransformKind, } from './contract.js';
|
|
5
|
+
export { AuthError, BadRequestError, ConflictError, ConnectionError, ContractCompileError, ContractGateError, HTTPError, NotFoundError, ServerError, ValidationFailedError, } from './errors.js';
|
|
6
|
+
export type { AuditEntry, BatchIngestResponse, ContractResponse, EgressOutcome, EgressResponse, IngestionStats, IngestEventResult, ValidationResult, Violation, ViolationKind, VersionResponse, VersionSummary, } from './types.js';
|
|
7
|
+
export { validate } from './validator.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { Client } from './client.js';
|
|
2
|
+
export { Contract } from './contract.js';
|
|
3
|
+
export { AuthError, BadRequestError, ConflictError, ConnectionError, ContractCompileError, ContractGateError, HTTPError, NotFoundError, ServerError, ValidationFailedError, } from './errors.js';
|
|
4
|
+
export { validate } from './validator.js';
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export type ViolationKind = 'missing_required_field' | 'type_mismatch' | 'pattern_mismatch' | 'enum_violation' | 'range_violation' | 'length_violation' | 'metric_range_violation' | 'unknown_field' | 'undeclared_field';
|
|
2
|
+
export interface Violation {
|
|
3
|
+
field: string;
|
|
4
|
+
message: string;
|
|
5
|
+
kind: ViolationKind;
|
|
6
|
+
}
|
|
7
|
+
export interface ValidationResult {
|
|
8
|
+
passed: boolean;
|
|
9
|
+
violations: Violation[];
|
|
10
|
+
validation_us: number;
|
|
11
|
+
}
|
|
12
|
+
export interface IngestEventResult {
|
|
13
|
+
passed: boolean;
|
|
14
|
+
violations: Violation[];
|
|
15
|
+
validation_us: number;
|
|
16
|
+
forwarded: boolean;
|
|
17
|
+
contract_version: string;
|
|
18
|
+
transformed_event: unknown;
|
|
19
|
+
}
|
|
20
|
+
export interface BatchIngestResponse {
|
|
21
|
+
total: number;
|
|
22
|
+
passed: number;
|
|
23
|
+
failed: number;
|
|
24
|
+
dry_run: boolean;
|
|
25
|
+
atomic: boolean;
|
|
26
|
+
resolved_version: string;
|
|
27
|
+
version_pin_source: string;
|
|
28
|
+
results: IngestEventResult[];
|
|
29
|
+
}
|
|
30
|
+
export interface EgressOutcome {
|
|
31
|
+
index: number;
|
|
32
|
+
passed: boolean;
|
|
33
|
+
violations: Violation[];
|
|
34
|
+
validation_us: number;
|
|
35
|
+
action: string;
|
|
36
|
+
}
|
|
37
|
+
export interface EgressResponse {
|
|
38
|
+
total: number;
|
|
39
|
+
passed: number;
|
|
40
|
+
failed: number;
|
|
41
|
+
dry_run: boolean;
|
|
42
|
+
disposition: string;
|
|
43
|
+
resolved_version: string;
|
|
44
|
+
payload: unknown[];
|
|
45
|
+
outcomes: EgressOutcome[];
|
|
46
|
+
}
|
|
47
|
+
export interface ContractResponse {
|
|
48
|
+
id: string;
|
|
49
|
+
name: string;
|
|
50
|
+
description: string | null;
|
|
51
|
+
multi_stable_resolution: string;
|
|
52
|
+
created_at: string;
|
|
53
|
+
updated_at: string;
|
|
54
|
+
version_count: number;
|
|
55
|
+
latest_stable_version: string | null;
|
|
56
|
+
}
|
|
57
|
+
export interface VersionResponse {
|
|
58
|
+
id: string;
|
|
59
|
+
contract_id: string;
|
|
60
|
+
version: string;
|
|
61
|
+
state: string;
|
|
62
|
+
yaml_content: string;
|
|
63
|
+
created_at: string;
|
|
64
|
+
promoted_at: string | null;
|
|
65
|
+
deprecated_at: string | null;
|
|
66
|
+
compliance_mode: boolean;
|
|
67
|
+
}
|
|
68
|
+
export interface VersionSummary {
|
|
69
|
+
version: string;
|
|
70
|
+
state: string;
|
|
71
|
+
created_at: string;
|
|
72
|
+
promoted_at: string | null;
|
|
73
|
+
deprecated_at: string | null;
|
|
74
|
+
}
|
|
75
|
+
export interface AuditEntry {
|
|
76
|
+
id: string;
|
|
77
|
+
contract_id: string;
|
|
78
|
+
contract_version: string | null;
|
|
79
|
+
passed: boolean;
|
|
80
|
+
violation_count: number;
|
|
81
|
+
violation_details: unknown;
|
|
82
|
+
raw_event: unknown;
|
|
83
|
+
validation_us: number;
|
|
84
|
+
source_ip: string | null;
|
|
85
|
+
created_at: string;
|
|
86
|
+
}
|
|
87
|
+
export interface IngestionStats {
|
|
88
|
+
total_events: number;
|
|
89
|
+
passed_events: number;
|
|
90
|
+
failed_events: number;
|
|
91
|
+
pass_rate: number;
|
|
92
|
+
avg_validation_us: number;
|
|
93
|
+
p50_validation_us: number;
|
|
94
|
+
p95_validation_us: number;
|
|
95
|
+
p99_validation_us: number;
|
|
96
|
+
}
|
|
97
|
+
export declare function violationFromJson(raw: unknown): Violation;
|
|
98
|
+
export declare function validationResultFromJson(raw: unknown): ValidationResult;
|
|
99
|
+
export declare function ingestEventResultFromJson(raw: unknown): IngestEventResult;
|
|
100
|
+
export declare function batchIngestResponseFromJson(raw: unknown): BatchIngestResponse;
|
|
101
|
+
export declare function egressOutcomeFromJson(raw: unknown): EgressOutcome;
|
|
102
|
+
export declare function egressResponseFromJson(raw: unknown): EgressResponse;
|
|
103
|
+
export declare function contractResponseFromJson(raw: unknown): ContractResponse;
|
|
104
|
+
export declare function versionResponseFromJson(raw: unknown): VersionResponse;
|
|
105
|
+
export declare function versionSummaryFromJson(raw: unknown): VersionSummary;
|
|
106
|
+
export declare function auditEntryFromJson(raw: unknown): AuditEntry;
|
|
107
|
+
export declare function ingestionStatsFromJson(raw: unknown): IngestionStats;
|
|
108
|
+
export declare function expectList(body: unknown): unknown[];
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
function toRaw(v) {
|
|
2
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) {
|
|
3
|
+
throw new Error(`Expected object, got ${JSON.stringify(v)}`);
|
|
4
|
+
}
|
|
5
|
+
return v;
|
|
6
|
+
}
|
|
7
|
+
function optStr(v) {
|
|
8
|
+
return typeof v === 'string' ? v : null;
|
|
9
|
+
}
|
|
10
|
+
function toBool(v, fallback = false) {
|
|
11
|
+
return typeof v === 'boolean' ? v : fallback;
|
|
12
|
+
}
|
|
13
|
+
function toInt(v, fallback = 0) {
|
|
14
|
+
return typeof v === 'number' ? Math.trunc(v) : fallback;
|
|
15
|
+
}
|
|
16
|
+
export function violationFromJson(raw) {
|
|
17
|
+
const r = toRaw(raw);
|
|
18
|
+
return {
|
|
19
|
+
field: String(r['field'] ?? ''),
|
|
20
|
+
message: String(r['message'] ?? ''),
|
|
21
|
+
kind: String(r['kind'] ?? ''),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function validationResultFromJson(raw) {
|
|
25
|
+
const r = toRaw(raw);
|
|
26
|
+
const vList = Array.isArray(r['violations']) ? r['violations'] : [];
|
|
27
|
+
return {
|
|
28
|
+
passed: toBool(r['passed']),
|
|
29
|
+
violations: vList.map(violationFromJson),
|
|
30
|
+
validation_us: toInt(r['validation_us']),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function ingestEventResultFromJson(raw) {
|
|
34
|
+
const r = toRaw(raw);
|
|
35
|
+
const vList = Array.isArray(r['violations']) ? r['violations'] : [];
|
|
36
|
+
return {
|
|
37
|
+
passed: toBool(r['passed']),
|
|
38
|
+
violations: vList.map(violationFromJson),
|
|
39
|
+
validation_us: toInt(r['validation_us']),
|
|
40
|
+
forwarded: toBool(r['forwarded']),
|
|
41
|
+
contract_version: String(r['contract_version'] ?? ''),
|
|
42
|
+
transformed_event: r['transformed_event'] ?? null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export function batchIngestResponseFromJson(raw) {
|
|
46
|
+
const r = toRaw(raw);
|
|
47
|
+
const rList = Array.isArray(r['results']) ? r['results'] : [];
|
|
48
|
+
return {
|
|
49
|
+
total: toInt(r['total']),
|
|
50
|
+
passed: toInt(r['passed']),
|
|
51
|
+
failed: toInt(r['failed']),
|
|
52
|
+
dry_run: toBool(r['dry_run']),
|
|
53
|
+
atomic: toBool(r['atomic']),
|
|
54
|
+
resolved_version: String(r['resolved_version'] ?? ''),
|
|
55
|
+
version_pin_source: String(r['version_pin_source'] ?? ''),
|
|
56
|
+
results: rList.map(ingestEventResultFromJson),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function egressOutcomeFromJson(raw) {
|
|
60
|
+
const r = toRaw(raw);
|
|
61
|
+
const vList = Array.isArray(r['violations']) ? r['violations'] : [];
|
|
62
|
+
return {
|
|
63
|
+
index: toInt(r['index']),
|
|
64
|
+
passed: toBool(r['passed']),
|
|
65
|
+
violations: vList.map(violationFromJson),
|
|
66
|
+
validation_us: toInt(r['validation_us']),
|
|
67
|
+
action: String(r['action'] ?? ''),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export function egressResponseFromJson(raw) {
|
|
71
|
+
const r = toRaw(raw);
|
|
72
|
+
const oList = Array.isArray(r['outcomes']) ? r['outcomes'] : [];
|
|
73
|
+
return {
|
|
74
|
+
total: toInt(r['total']),
|
|
75
|
+
passed: toInt(r['passed']),
|
|
76
|
+
failed: toInt(r['failed']),
|
|
77
|
+
dry_run: toBool(r['dry_run']),
|
|
78
|
+
disposition: String(r['disposition'] ?? 'block'),
|
|
79
|
+
resolved_version: String(r['resolved_version'] ?? ''),
|
|
80
|
+
payload: Array.isArray(r['payload']) ? r['payload'] : [],
|
|
81
|
+
outcomes: oList.map(egressOutcomeFromJson),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function contractResponseFromJson(raw) {
|
|
85
|
+
const r = toRaw(raw);
|
|
86
|
+
return {
|
|
87
|
+
id: String(r['id'] ?? ''),
|
|
88
|
+
name: String(r['name'] ?? ''),
|
|
89
|
+
description: optStr(r['description']),
|
|
90
|
+
multi_stable_resolution: String(r['multi_stable_resolution'] ?? 'strict'),
|
|
91
|
+
created_at: String(r['created_at'] ?? ''),
|
|
92
|
+
updated_at: String(r['updated_at'] ?? ''),
|
|
93
|
+
version_count: toInt(r['version_count']),
|
|
94
|
+
latest_stable_version: optStr(r['latest_stable_version']),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function versionResponseFromJson(raw) {
|
|
98
|
+
const r = toRaw(raw);
|
|
99
|
+
return {
|
|
100
|
+
id: String(r['id'] ?? ''),
|
|
101
|
+
contract_id: String(r['contract_id'] ?? ''),
|
|
102
|
+
version: String(r['version'] ?? ''),
|
|
103
|
+
state: String(r['state'] ?? ''),
|
|
104
|
+
yaml_content: String(r['yaml_content'] ?? ''),
|
|
105
|
+
created_at: String(r['created_at'] ?? ''),
|
|
106
|
+
promoted_at: optStr(r['promoted_at']),
|
|
107
|
+
deprecated_at: optStr(r['deprecated_at']),
|
|
108
|
+
compliance_mode: toBool(r['compliance_mode']),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export function versionSummaryFromJson(raw) {
|
|
112
|
+
const r = toRaw(raw);
|
|
113
|
+
return {
|
|
114
|
+
version: String(r['version'] ?? ''),
|
|
115
|
+
state: String(r['state'] ?? ''),
|
|
116
|
+
created_at: String(r['created_at'] ?? ''),
|
|
117
|
+
promoted_at: optStr(r['promoted_at']),
|
|
118
|
+
deprecated_at: optStr(r['deprecated_at']),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export function auditEntryFromJson(raw) {
|
|
122
|
+
const r = toRaw(raw);
|
|
123
|
+
return {
|
|
124
|
+
id: String(r['id'] ?? ''),
|
|
125
|
+
contract_id: String(r['contract_id'] ?? ''),
|
|
126
|
+
contract_version: optStr(r['contract_version']),
|
|
127
|
+
passed: toBool(r['passed']),
|
|
128
|
+
violation_count: toInt(r['violation_count']),
|
|
129
|
+
violation_details: r['violation_details'] ?? null,
|
|
130
|
+
raw_event: r['raw_event'] ?? null,
|
|
131
|
+
validation_us: toInt(r['validation_us']),
|
|
132
|
+
source_ip: optStr(r['source_ip']),
|
|
133
|
+
created_at: String(r['created_at'] ?? ''),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export function ingestionStatsFromJson(raw) {
|
|
137
|
+
const r = toRaw(raw);
|
|
138
|
+
return {
|
|
139
|
+
total_events: toInt(r['total_events']),
|
|
140
|
+
passed_events: toInt(r['passed_events']),
|
|
141
|
+
failed_events: toInt(r['failed_events']),
|
|
142
|
+
pass_rate: typeof r['pass_rate'] === 'number' ? r['pass_rate'] : 0,
|
|
143
|
+
avg_validation_us: typeof r['avg_validation_us'] === 'number' ? r['avg_validation_us'] : 0,
|
|
144
|
+
p50_validation_us: toInt(r['p50_validation_us']),
|
|
145
|
+
p95_validation_us: toInt(r['p95_validation_us']),
|
|
146
|
+
p99_validation_us: toInt(r['p99_validation_us']),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export function expectList(body) {
|
|
150
|
+
if (body === null || body === undefined)
|
|
151
|
+
return [];
|
|
152
|
+
if (Array.isArray(body))
|
|
153
|
+
return body;
|
|
154
|
+
throw new Error(`Expected list response, got ${typeof body}`);
|
|
155
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { fieldTypeDisplay } from './contract.js';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Public entry point
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
export function validate(compiled, event) {
|
|
6
|
+
const t0 = performance.now();
|
|
7
|
+
const violations = [];
|
|
8
|
+
if (event === null || typeof event !== 'object' || Array.isArray(event)) {
|
|
9
|
+
return {
|
|
10
|
+
passed: false,
|
|
11
|
+
violations: [
|
|
12
|
+
{
|
|
13
|
+
field: '<root>',
|
|
14
|
+
message: 'Event must be a JSON object',
|
|
15
|
+
kind: 'type_mismatch',
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
validation_us: 0,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const ev = event;
|
|
22
|
+
// 1. Ontology fields
|
|
23
|
+
validateFields(compiled.contract.entities, ev, '', compiled.patterns, violations);
|
|
24
|
+
// 2. Metric definitions
|
|
25
|
+
for (const metric of compiled.contract.metrics) {
|
|
26
|
+
validateMetric(metric, ev, violations);
|
|
27
|
+
}
|
|
28
|
+
// 3. Compliance-mode undeclared field check (last — keeps standard violations
|
|
29
|
+
// first, matching Rust order so triage workflows need no special-casing).
|
|
30
|
+
if (compiled.contract.complianceMode) {
|
|
31
|
+
for (const key of Object.keys(ev)) {
|
|
32
|
+
if (!compiled.declaredTopLevelFields.has(key)) {
|
|
33
|
+
violations.push({
|
|
34
|
+
field: key,
|
|
35
|
+
message: `Field '${key}' is not declared in the contract ontology. ` +
|
|
36
|
+
'Compliance mode rejects undeclared fields.',
|
|
37
|
+
kind: 'undeclared_field',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const elapsed_us = Math.round((performance.now() - t0) * 1000);
|
|
43
|
+
return { passed: violations.length === 0, violations, validation_us: elapsed_us };
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Field walker
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
function validateFields(fields, data, prefix, patterns, violations) {
|
|
49
|
+
for (const f of fields) {
|
|
50
|
+
const path = prefix ? `${prefix}.${f.name}` : f.name;
|
|
51
|
+
if (!(f.name in data)) {
|
|
52
|
+
if (f.required) {
|
|
53
|
+
violations.push({
|
|
54
|
+
field: path,
|
|
55
|
+
message: `Required field '${f.name}' is missing`,
|
|
56
|
+
kind: 'missing_required_field',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
validateValue(f, data[f.name], path, patterns, violations);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function validateValue(f, value, path, patterns, violations) {
|
|
65
|
+
// --- Type check ---
|
|
66
|
+
if (!typeMatches(f.fieldType, value)) {
|
|
67
|
+
violations.push({
|
|
68
|
+
field: path,
|
|
69
|
+
message: `Field '${path}' expected type ${fieldTypeDisplay(f.fieldType)}, ` +
|
|
70
|
+
`got ${jsonTypeName(value)}`,
|
|
71
|
+
kind: 'type_mismatch',
|
|
72
|
+
});
|
|
73
|
+
return; // Further checks on the wrong type are noise.
|
|
74
|
+
}
|
|
75
|
+
// --- String checks ---
|
|
76
|
+
if (typeof value === 'string') {
|
|
77
|
+
if (f.minLength != null && value.length < f.minLength) {
|
|
78
|
+
violations.push({
|
|
79
|
+
field: path,
|
|
80
|
+
message: `Field '${path}' length ${value.length} is below minimum ${f.minLength}`,
|
|
81
|
+
kind: 'length_violation',
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (f.maxLength != null && value.length > f.maxLength) {
|
|
85
|
+
violations.push({
|
|
86
|
+
field: path,
|
|
87
|
+
message: `Field '${path}' length ${value.length} exceeds maximum ${f.maxLength}`,
|
|
88
|
+
kind: 'length_violation',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const regex = patterns.get(path);
|
|
92
|
+
if (regex != null && !regex.test(value)) {
|
|
93
|
+
violations.push({
|
|
94
|
+
field: path,
|
|
95
|
+
message: `Field '${path}' value ${JSON.stringify(value)} does not match required pattern`,
|
|
96
|
+
kind: 'pattern_mismatch',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// --- Numeric range checks ---
|
|
101
|
+
const n = numericValue(value);
|
|
102
|
+
if (n !== null) {
|
|
103
|
+
if (f.min != null && n < f.min) {
|
|
104
|
+
violations.push({
|
|
105
|
+
field: path,
|
|
106
|
+
message: `Field '${path}' value ${formatNumber(n)} is below minimum ${formatNumber(f.min)}`,
|
|
107
|
+
kind: 'range_violation',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (f.max != null && n > f.max) {
|
|
111
|
+
violations.push({
|
|
112
|
+
field: path,
|
|
113
|
+
message: `Field '${path}' value ${formatNumber(n)} exceeds maximum ${formatNumber(f.max)}`,
|
|
114
|
+
kind: 'range_violation',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// --- Enum check ---
|
|
119
|
+
if (f.allowedValues != null && !f.allowedValues.some((av) => deepEqual(av, value))) {
|
|
120
|
+
const rendered = f.allowedValues.map((v) => JSON.stringify(v)).join(', ');
|
|
121
|
+
violations.push({
|
|
122
|
+
field: path,
|
|
123
|
+
message: `Field '${path}' value ${JSON.stringify(value)} not in allowed set: [${rendered}]`,
|
|
124
|
+
kind: 'enum_violation',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
// --- Recurse into nested objects ---
|
|
128
|
+
if (f.fieldType === 'object' && f.properties && typeof value === 'object' && value !== null) {
|
|
129
|
+
validateFields(f.properties, value, path, patterns, violations);
|
|
130
|
+
}
|
|
131
|
+
// --- Recurse into array items ---
|
|
132
|
+
if (f.fieldType === 'array' && f.items != null && Array.isArray(value)) {
|
|
133
|
+
for (let i = 0; i < value.length; i++) {
|
|
134
|
+
validateValue(f.items, value[i], `${path}[${i}]`, patterns, violations);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Metric walker
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
function validateMetric(metric, event, violations) {
|
|
142
|
+
if (metric.field == null)
|
|
143
|
+
return; // formula-only metric
|
|
144
|
+
if (metric.min == null && metric.max == null)
|
|
145
|
+
return;
|
|
146
|
+
const rawValue = resolvePath(event, metric.field);
|
|
147
|
+
const n = rawValue != null ? numericValue(rawValue) : null;
|
|
148
|
+
if (n === null) {
|
|
149
|
+
violations.push({
|
|
150
|
+
field: metric.field,
|
|
151
|
+
message: `Metric '${metric.name}' field '${metric.field}' is missing or not numeric`,
|
|
152
|
+
kind: 'missing_required_field',
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (metric.min != null && n < metric.min) {
|
|
157
|
+
violations.push({
|
|
158
|
+
field: metric.field,
|
|
159
|
+
message: `Metric '${metric.name}' value ${formatNumber(n)} is below ` +
|
|
160
|
+
`minimum ${formatNumber(metric.min)} (field: '${metric.field}')`,
|
|
161
|
+
kind: 'metric_range_violation',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (metric.max != null && n > metric.max) {
|
|
165
|
+
violations.push({
|
|
166
|
+
field: metric.field,
|
|
167
|
+
message: `Metric '${metric.name}' value ${formatNumber(n)} exceeds ` +
|
|
168
|
+
`maximum ${formatNumber(metric.max)} (field: '${metric.field}')`,
|
|
169
|
+
kind: 'metric_range_violation',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function typeMatches(ft, value) {
|
|
174
|
+
switch (ft) {
|
|
175
|
+
case 'string': return typeof value === 'string';
|
|
176
|
+
// JSON booleans are typeof 'boolean', not 'number', so Number.isInteger
|
|
177
|
+
// correctly rejects them without an extra boolean guard.
|
|
178
|
+
case 'integer': return typeof value === 'number' && Number.isInteger(value);
|
|
179
|
+
case 'float': return typeof value === 'number';
|
|
180
|
+
case 'boolean': return typeof value === 'boolean';
|
|
181
|
+
case 'object':
|
|
182
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
183
|
+
case 'array': return Array.isArray(value);
|
|
184
|
+
case 'any': return true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function numericValue(value) {
|
|
188
|
+
if (typeof value === 'number')
|
|
189
|
+
return value;
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
function resolvePath(value, path) {
|
|
193
|
+
let current = value;
|
|
194
|
+
for (const key of path.split('.')) {
|
|
195
|
+
if (current === null || typeof current !== 'object' || Array.isArray(current)) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
current = current[key];
|
|
199
|
+
if (current == null)
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return current;
|
|
203
|
+
}
|
|
204
|
+
function jsonTypeName(value) {
|
|
205
|
+
if (value === null)
|
|
206
|
+
return 'null';
|
|
207
|
+
if (typeof value === 'boolean')
|
|
208
|
+
return 'boolean';
|
|
209
|
+
if (typeof value === 'number') {
|
|
210
|
+
return Number.isInteger(value) ? 'integer' : 'float';
|
|
211
|
+
}
|
|
212
|
+
if (typeof value === 'string')
|
|
213
|
+
return 'string';
|
|
214
|
+
if (Array.isArray(value))
|
|
215
|
+
return 'array';
|
|
216
|
+
if (typeof value === 'object')
|
|
217
|
+
return 'object';
|
|
218
|
+
return typeof value;
|
|
219
|
+
}
|
|
220
|
+
function formatNumber(n) {
|
|
221
|
+
// Match Rust's Display: whole numbers render without a decimal point.
|
|
222
|
+
// In JS, String(5.0) === "5" already, but be explicit for large integers.
|
|
223
|
+
if (Number.isInteger(n))
|
|
224
|
+
return String(n);
|
|
225
|
+
return String(n);
|
|
226
|
+
}
|
|
227
|
+
function deepEqual(a, b) {
|
|
228
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
229
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nightmoose/contractgate-sdk",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Official TypeScript SDK for ContractGate — HTTP client + local validator",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"prepublishOnly": "npm run build",
|
|
25
|
+
"test": "tsx --test tests/*.test.ts",
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/nightmoose/contractgate.git",
|
|
34
|
+
"directory": "sdks/typescript"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"contractgate",
|
|
38
|
+
"data-contracts",
|
|
39
|
+
"validation",
|
|
40
|
+
"sdk"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"js-yaml": "^4.1.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/js-yaml": "^4.0.9",
|
|
47
|
+
"@types/node": "^22.0.0",
|
|
48
|
+
"tsx": "^4.19.0",
|
|
49
|
+
"typescript": "^5.8.0"
|
|
50
|
+
}
|
|
51
|
+
}
|