@inpolicy/detect-contract
v0.3.0
Published
Single source of truth for the InPolicy extension → backend violation-detection request and the NDJSON response frames. Zod schemas consumed by the NestJS backend (drift gate) and the Chrome extension (request builder).
Readme
@inpolicy/detect-contract
Single source of truth for the extension ↔ backend detection wire:
DetectRequestSchema— the extension-owned request payload (.strict(); unknown keys rejected).DetectFrameSchema— the NDJSON response frames the client reads (.passthrough(); forward-compatible).ExtensionConfigSchema— the remote config document the client pulls fromGET /api/v1/extension/config(.passthrough(); forward-compatible).
All are zod schemas, matching the house style in @inpolicy/shared. See ADR docs/adr/0006-detection-end-to-end-ownership-and-contracts.md for the layer-ownership map, docs/specs/detect-contract-extension-integration.md for the extension setup, and docs/specs/2026-07-20-extension-remote-config-spec.md for the remote-config design.
Why
The extension and backend used to share no contract, so they drifted both ways: the extension sent streamMode (the backend never read it) and the backend added fields the extension never sent. This package makes the request shape a shared artifact with a drift gate: the backend conformance test fails if ViolationDetectionRequestDto and DetectRequestSchema disagree, and the extension's .strict() conformance test fails if its request builder emits a field the contract doesn't allow.
What's in it (and what isn't)
DetectRequestSchema holds ONLY the fields the extension owns. The backend enriches the payload with tenant context (tenant_id, user_country, user_team_ids, context_inlined, business_profile, tenant_entities) before forwarding to ai-python — those are the backend's job and are deliberately absent here.
import { DetectRequestSchema, type DetectRequest } from "@inpolicy/detect-contract";
const req: DetectRequest = { text, stablePrefix, contentType: "email" };
const validated = DetectRequestSchema.parse(req); // throws on unknown keysUsage
pnpm add @inpolicy/detect-contractReading frames off the NDJSON stream:
import { DetectFrameSchema } from "@inpolicy/detect-contract";
for (const line of ndjsonLines) {
const frame = DetectFrameSchema.parse(JSON.parse(line));
if (frame.type === "violation") render(frame.violation);
}Fetching + validating the remote config (extension side):
import { ExtensionConfigSchema } from "@inpolicy/detect-contract";
const res = await fetch("/api/v1/extension/config", { headers: { Authorization } });
const parsed = ExtensionConfigSchema.safeParse(await res.json());
const config = parsed.success ? parsed.data : lastGoodConfig; // never brick on a bad body
if (config.surfaces[surface]?.streaming) useNdjson();The config is liberal on read (.passthrough()): unknown top-level keys and unknown surface keys are tolerated, and the client falls back to its baked defaults on missing keys — so a server-side flag rollout never needs a coordinated extension release. The backend is the authoritative writer (it validates, clamps debounceMs to [100, 5000], and content-hashes into version); the write-input shape is a backend-internal concern and is intentionally not exported.
Build & test
pnpm --filter @inpolicy/detect-contract build # tsc → dist/
pnpm --filter @inpolicy/detect-contract test # jestPublishing
This is a public org-scoped package (publishConfig.access = "public"), published to npmjs.org under the @inpolicy scope via the repo's changesets flow (.github/workflows/release.yml), same as @inpolicy/sdk. It is public because the @inpolicy npm org is on the free tier (which can't host private packages) and the contract holds no secrets. If it ever moves to a private paid org, flip access back to "restricted".
It is not published automatically yet — publishing is deferred to the owner. To cut the first release:
- Add a changeset:
pnpm changeset→ select@inpolicy/detect-contract, choose the bump. - Add the package to the
release.ymlbuild/test filters (the--filter='@inpolicy/...'lists), so the release job builds and tests it before publishing. - Merge to
main; the changesets action opens/lands the version PR and runschangeset publish.
Because it is public, the separate extension repo consumes it with a plain npm install @inpolicy/detect-contract - no .npmrc or token needed. See the integration spec.
