From 332180458d1bc48fded6a3a0ba66c4bd63d6b79e Mon Sep 17 00:00:00 2001 From: Shaishav Pidadi Date: Tue, 12 May 2026 16:43:15 -0400 Subject: [PATCH 1/6] chore(pkg): add publishConfig.access=public for scoped npm publish (task 2.1.1) --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index eb70410..a9131dc 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "README.md", "LICENSE" ], + "publishConfig": { + "access": "public" + }, "scripts": { "build": "tsc", "dev": "tsc --watch", From cfe5c8c5e01e725a5ca6b8fc777d37cad73d80a1 Mon Sep 17 00:00:00 2001 From: Shaishav Pidadi Date: Tue, 12 May 2026 16:43:59 -0400 Subject: [PATCH 2/6] ci(publish): gate npm publish behind workflow_dispatch + tag-version check (task 2.2.1) --- .github/workflows/publish.yml | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 06e91b1..6909049 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,9 +1,17 @@ name: Publish to npm +# Manual-only by design (ADR-005 sibling decision, 2026-05-12): +# We push tags as release markers but the actual `npm publish` is gated behind +# a manual `workflow_dispatch` so the founder controls when the package ships. +# Re-enable the tag trigger only after the npm org + token are confirmed. + on: - push: - tags: - - "v*" + workflow_dispatch: + inputs: + tag: + description: "Existing git tag to publish (e.g., v1.0.0-alpha.14)" + required: true + type: string jobs: publish: @@ -15,6 +23,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} - uses: actions/setup-node@v4 with: @@ -22,6 +32,16 @@ jobs: cache: npm registry-url: "https://registry.npmjs.org" + - name: Verify package.json version matches tag + run: | + PKG=$(jq -r .version package.json) + TAG="${{ inputs.tag }}" + TAG_VER="${TAG#v}" + if [ "$PKG" != "$TAG_VER" ]; then + echo "::error::package.json version ($PKG) != tag ($TAG_VER)" + exit 1 + fi + - run: npm ci - name: Build From 14ff8f6da22a13132053288ade7b24192d7e6436 Mon Sep 17 00:00:00 2001 From: Shaishav Pidadi Date: Tue, 12 May 2026 16:44:43 -0400 Subject: [PATCH 3/6] =?UTF-8?q?chore(release):=20bump=20to=201.0.0-alpha.1?= =?UTF-8?q?5=20=E2=80=94=20alpha.14=20tag=20already=20published=20at=20old?= =?UTF-8?q?er=20sha=20(task=202.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a9131dc..9bddf6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@governs-ai/sdk", - "version": "1.0.0-alpha.14", + "version": "1.0.0-alpha.15", "description": "TypeScript SDK for GovernsAI - AI governance platform with unified memory service", "main": "dist/index.js", "types": "dist/index.d.ts", From 4be953e1400fb21910fd1e22a3cda0f1447a717b Mon Sep 17 00:00:00 2001 From: Shaishav Pidadi Date: Thu, 14 May 2026 13:55:08 -0400 Subject: [PATCH 4/6] feat(D.5+D.6): Express + Next.js middleware (precheck wrap, deny/transform/allow flow); 20 unit tests --- src/__tests__/middleware-express.test.ts | 136 ++++++++++++++++++++++ src/__tests__/middleware-nextjs.test.ts | 138 +++++++++++++++++++++++ src/middleware/express.ts | 133 ++++++++++++++++++++++ src/middleware/nextjs.ts | 126 +++++++++++++++++++++ src/middleware/precheck-fetch.ts | 85 ++++++++++++++ 5 files changed, 618 insertions(+) create mode 100644 src/__tests__/middleware-express.test.ts create mode 100644 src/__tests__/middleware-nextjs.test.ts create mode 100644 src/middleware/express.ts create mode 100644 src/middleware/nextjs.ts create mode 100644 src/middleware/precheck-fetch.ts diff --git a/src/__tests__/middleware-express.test.ts b/src/__tests__/middleware-express.test.ts new file mode 100644 index 0000000..0b46ba6 --- /dev/null +++ b/src/__tests__/middleware-express.test.ts @@ -0,0 +1,136 @@ +/** + * Tests for the Express middleware. Stubs fetch to return controlled + * precheck responses, then asserts request flow. + */ +import { governsExpress } from '../middleware/express'; + +function makeReq(body: any = {}, path = '/api/chat'): any { + return { body, path, headers: {} }; +} + +function makeRes(): any { + const calls: { status?: number; json?: any; headers: Record } = { headers: {} }; + return { + calls, + status(c: number) { calls.status = c; return this; }, + json(b: any) { calls.json = b; }, + setHeader(k: string, v: string) { calls.headers[k] = v; }, + }; +} + +function fakeFetch(decision: string, rawTextOut = '', reasons: string[] = [], policyId?: string) { + return jest.fn(async (_url: any, _init: any) => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + decision, raw_text_out: rawTextOut, reasons, policy_id: policyId, + }), + })); +} + +describe('governsExpress middleware', () => { + it('passes through with no input', async () => { + const mw = governsExpress({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: fakeFetch('allow') as any, + }); + const req = makeReq({}); const res = makeRes(); const next = jest.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalled(); + expect(res.calls.status).toBeUndefined(); + }); + + it('skips when skip() returns true', async () => { + const fetchSpy = fakeFetch('allow'); + const mw = governsExpress({ + apiKey: 'k', baseUrl: 'http://t', + skip: () => true, + fetchImpl: fetchSpy as any, + }); + const req = makeReq({ prompt: 'x' }); const res = makeRes(); const next = jest.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns 403 on deny', async () => { + const mw = governsExpress({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: fakeFetch('deny', '', ['pii.email'], 'p-1') as any, + }); + const req = makeReq({ prompt: 'jane@example.com' }); const res = makeRes(); const next = jest.fn(); + await mw(req, res, next); + expect(res.calls.status).toBe(403); + expect(res.calls.json).toMatchObject({ + error: 'governance_denied', + reasons: ['pii.email'], + policyId: 'p-1', + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('rewrites body on transform and calls next', async () => { + const mw = governsExpress({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: fakeFetch('transform', 'contact me at ') as any, + }); + const req = makeReq({ prompt: 'contact me at jane@example.com' }); + const res = makeRes(); const next = jest.fn(); + await mw(req, res, next); + expect(req.body.prompt).toBe('contact me at '); + expect(next).toHaveBeenCalled(); + expect(res.calls.headers['x-governs-decision']).toBe('transform'); + }); + + it('passes through on allow', async () => { + const mw = governsExpress({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: fakeFetch('allow') as any, + }); + const req = makeReq({ prompt: 'hi' }); const res = makeRes(); const next = jest.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + it('returns 503 on precheck error when onError=block', async () => { + const failingFetch = jest.fn(async () => { throw new Error('econnrefused'); }); + const mw = governsExpress({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: failingFetch as any, + onError: 'block', + }); + const req = makeReq({ prompt: 'hi' }); const res = makeRes(); const next = jest.fn(); + await mw(req, res, next); + expect(res.calls.status).toBe(503); + expect(next).not.toHaveBeenCalled(); + }); + + it('passes through on precheck error when onError=pass', async () => { + const failingFetch = jest.fn(async () => { throw new Error('boom'); }); + const mw = governsExpress({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: failingFetch as any, + onError: 'pass', + }); + const req = makeReq({ prompt: 'hi' }); const res = makeRes(); const next = jest.fn(); + await mw(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + it('uses custom pickInput and pickTool', async () => { + const fetchSpy = fakeFetch('allow'); + const mw = governsExpress({ + apiKey: 'k', baseUrl: 'http://t', + pickInput: (req) => req.body.message, + pickTool: () => 'agent.call', + fetchImpl: fetchSpy as any, + }); + const req = makeReq({ message: 'check this' }); const res = makeRes(); const next = jest.fn(); + await mw(req, res, next); + expect(fetchSpy).toHaveBeenCalled(); + const call = fetchSpy.mock.calls[0]!; + const body = JSON.parse((call[1] as any).body); + expect(body.tool).toBe('agent.call'); + expect(body.raw_text).toBe('check this'); + }); +}); diff --git a/src/__tests__/middleware-nextjs.test.ts b/src/__tests__/middleware-nextjs.test.ts new file mode 100644 index 0000000..72724d5 --- /dev/null +++ b/src/__tests__/middleware-nextjs.test.ts @@ -0,0 +1,138 @@ +/** + * Tests for the Next.js middleware helper. + */ +import { governsNextMiddleware } from '../middleware/nextjs'; + +function makeReq(opts: { path?: string; method?: string; body?: any; clonedJsonError?: boolean }): any { + const body = opts.body ?? {}; + const headers = new Headers(); + return { + nextUrl: { pathname: opts.path ?? '/api/chat' }, + method: opts.method ?? 'POST', + headers, + clone() { return this; }, + async json() { + if (opts.clonedJsonError) throw new Error('not json'); + return body; + }, + }; +} + +function fakeFetch(decision: string, rawTextOut = '', reasons: string[] = [], policyId?: string) { + return jest.fn(async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ + decision, raw_text_out: rawTextOut, reasons, policy_id: policyId, + }), + })); +} + +describe('governsNextMiddleware', () => { + it('returns undefined for GET requests', async () => { + const mw = governsNextMiddleware({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: fakeFetch('allow') as any, + }); + const res = await mw(makeReq({ method: 'GET' })); + expect(res).toBeUndefined(); + }); + + it('returns undefined when path does not match', async () => { + const fetchSpy = fakeFetch('allow'); + const mw = governsNextMiddleware({ + apiKey: 'k', baseUrl: 'http://t', + matchPath: (p) => p.startsWith('/api/agent'), + fetchImpl: fetchSpy as any, + }); + const res = await mw(makeReq({ path: '/api/chat', body: { prompt: 'hi' } })); + expect(res).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns undefined when body is not JSON', async () => { + const mw = governsNextMiddleware({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: fakeFetch('allow') as any, + }); + const res = await mw(makeReq({ clonedJsonError: true })); + expect(res).toBeUndefined(); + }); + + it('returns undefined when input is missing', async () => { + const mw = governsNextMiddleware({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: fakeFetch('allow') as any, + }); + const res = await mw(makeReq({ body: {} })); + expect(res).toBeUndefined(); + }); + + it('returns 403 on deny', async () => { + const mw = governsNextMiddleware({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: fakeFetch('deny', '', ['pii.email'], 'p-1') as any, + }); + const res = await mw(makeReq({ body: { prompt: 'jane@example.com' } })); + expect(res).toBeDefined(); + expect(res!.status).toBe(403); + expect(res!.headers.get('x-governs-decision')).toBe('deny'); + expect(res!.headers.get('x-governs-policy')).toBe('p-1'); + const body = await res!.json(); + expect((body as any).error).toBe('governance_denied'); + expect((body as any).reasons).toEqual(['pii.email']); + }); + + it('returns undefined on transform (lets next continue; header set is on req copy)', async () => { + const mw = governsNextMiddleware({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: fakeFetch('transform', 'redacted') as any, + }); + const res = await mw(makeReq({ body: { prompt: 'jane@example.com' } })); + expect(res).toBeUndefined(); + }); + + it('returns undefined on allow', async () => { + const mw = governsNextMiddleware({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: fakeFetch('allow') as any, + }); + const res = await mw(makeReq({ body: { prompt: 'hi' } })); + expect(res).toBeUndefined(); + }); + + it('returns 503 on precheck error when onError=block (default)', async () => { + const failingFetch = jest.fn(async () => { throw new Error('boom'); }); + const mw = governsNextMiddleware({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: failingFetch as any, + }); + const res = await mw(makeReq({ body: { prompt: 'hi' } })); + expect(res!.status).toBe(503); + }); + + it('returns undefined on precheck error when onError=pass', async () => { + const failingFetch = jest.fn(async () => { throw new Error('boom'); }); + const mw = governsNextMiddleware({ + apiKey: 'k', baseUrl: 'http://t', + fetchImpl: failingFetch as any, + onError: 'pass', + }); + const res = await mw(makeReq({ body: { prompt: 'hi' } })); + expect(res).toBeUndefined(); + }); + + it('uses custom pickInput', async () => { + const fetchSpy = fakeFetch('allow'); + const mw = governsNextMiddleware({ + apiKey: 'k', baseUrl: 'http://t', + pickInput: (b) => b.message, + fetchImpl: fetchSpy as any, + }); + await mw(makeReq({ body: { message: 'hello' } })); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const call = (fetchSpy.mock.calls as any[])[0] as any[]; + const body = JSON.parse(call[1].body); + expect(body.raw_text).toBe('hello'); + }); +}); diff --git a/src/middleware/express.ts b/src/middleware/express.ts new file mode 100644 index 0000000..2a28a87 --- /dev/null +++ b/src/middleware/express.ts @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +/** + * Express middleware — runs every request body through GovernsAI precheck + * before your route handler sees it. + * + * Usage: + * + * import express from 'express'; + * import { governsExpress } from '@governs-ai/sdk/middleware/express'; + * + * const app = express(); + * app.use(express.json()); + * app.use(governsExpress({ + * apiKey: process.env.GOVERNS_AI_API_KEY!, + * baseUrl: process.env.GOVERNS_AI_BASE_URL ?? 'http://localhost:8082', + * pickInput: (req) => req.body?.prompt ?? '', + * // Optional: skip prechecks on some paths. + * skip: (req) => req.path.startsWith('/health'), + * })); + * + * Behavior: + * - decision === 'deny' → respond 403 with reasons, do NOT call next() + * - decision === 'transform' → mutate the picked field in `req.body` to the + * redacted text, then call next() + * - decision === 'allow' → just call next() + * - on any precheck error → behavior driven by `onError` (default 'block') + * + * Adds `req.governsPrecheck` containing the full PrecheckResult for downstream. + */ +import type { PrecheckCallOptions, PrecheckResult } from './precheck-fetch'; +import { callPrecheck } from './precheck-fetch'; + +export type ExpressLikeReq = { + body?: any; + path?: string; + headers?: Record; + [k: string]: any; +}; +export type ExpressLikeRes = { + status(code: number): ExpressLikeRes; + json(body: any): void; + setHeader?(name: string, value: string): void; + [k: string]: any; +}; +export type NextFn = (err?: any) => void; + +export interface GovernsExpressOptions extends Omit { + /** Pick the input text from the request. Default: req.body?.input ?? req.body?.prompt ?? '' */ + pickInput?: (req: ExpressLikeReq) => string; + /** Pick the tool name. Default: 'chat'. */ + pickTool?: (req: ExpressLikeReq) => string; + /** Skip precheck for this request (e.g. health endpoints). */ + skip?: (req: ExpressLikeReq) => boolean; + /** What to do on precheck call failure. 'block' (default) = 503; 'pass' = let through. */ + onError?: 'block' | 'pass'; + /** Where to write the transformed text back into req.body. Default: same key as pickInput pulled from. */ + writeBackKey?: string; +} + +const DEFAULT_PICK = (req: ExpressLikeReq): string => + (req.body && (req.body.input ?? req.body.prompt ?? req.body.text)) ?? ''; + +const DEFAULT_TOOL = (_req: ExpressLikeReq): string => 'chat'; + +export function governsExpress(options: GovernsExpressOptions) { + const pickInput = options.pickInput ?? DEFAULT_PICK; + const pickTool = options.pickTool ?? DEFAULT_TOOL; + const onError = options.onError ?? 'block'; + + return async function governsMiddleware(req: ExpressLikeReq, res: ExpressLikeRes, next: NextFn): Promise { + try { + if (options.skip && options.skip(req)) return next(); + + const rawText = pickInput(req); + if (!rawText) return next(); // nothing to check + + let result: PrecheckResult; + try { + const callOpts: PrecheckCallOptions = { + apiKey: options.apiKey, + baseUrl: options.baseUrl, + tool: pickTool(req), + rawText, + }; + if (options.timeoutMs !== undefined) callOpts.timeoutMs = options.timeoutMs; + if (options.fetchImpl !== undefined) callOpts.fetchImpl = options.fetchImpl; + result = await callPrecheck(callOpts); + } catch (err) { + if (onError === 'pass') return next(); + res.status(503).json({ + error: 'governance_precheck_unavailable', + detail: (err as Error)?.message ?? 'unknown', + }); + return; + } + + // Surface decision on response headers for downstream observability. + if (typeof res.setHeader === 'function') { + res.setHeader('x-governs-decision', result.decision); + if (result.policyId) res.setHeader('x-governs-policy', result.policyId); + } + (req as any).governsPrecheck = result; + + switch (result.decision) { + case 'deny': + res.status(403).json({ + error: 'governance_denied', + reasons: result.reasons, + policyId: result.policyId, + }); + return; + case 'transform': { + // write redacted text back into req.body + if (req.body && typeof req.body === 'object') { + const key = options.writeBackKey + ?? (req.body.input !== undefined ? 'input' + : req.body.prompt !== undefined ? 'prompt' + : req.body.text !== undefined ? 'text' + : null); + if (key) req.body[key] = result.rawTextOut; + } + return next(); + } + case 'allow': + case 'confirm': + default: + return next(); + } + } catch (e) { + return next(e); + } + }; +} diff --git a/src/middleware/nextjs.ts b/src/middleware/nextjs.ts new file mode 100644 index 0000000..fa92d6e --- /dev/null +++ b/src/middleware/nextjs.ts @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: MIT +/** + * Next.js middleware helper — runs a precheck on incoming requests with + * a JSON body. Works in both Edge (preferred) and Node runtimes. + * + * Usage in `middleware.ts`: + * + * import { governsNextMiddleware } from '@governs-ai/sdk/middleware/nextjs'; + * + * export const middleware = governsNextMiddleware({ + * apiKey: process.env.GOVERNS_AI_API_KEY!, + * baseUrl: process.env.GOVERNS_AI_BASE_URL ?? 'http://localhost:8082', + * // run only on AI-facing API paths + * matchPath: (pathname) => pathname.startsWith('/api/chat') + * || pathname.startsWith('/api/agent'), + * }); + * + * export const config = { matcher: ['/api/:path*'] }; + * + * On `decision === 'deny'` returns 403 JSON. + * On `decision === 'transform'` rewrites the request body by setting + * `x-governs-redacted-text` header (Edge runtime can't mutate request body + * directly; route handlers read this header). + * On `decision === 'allow'` passes through with a `x-governs-decision: allow` + * header for observability. + */ + +import type { PrecheckCallOptions, PrecheckResult } from './precheck-fetch'; +import { callPrecheck } from './precheck-fetch'; + +/** Minimal shape — avoids importing 'next/server' so SDK has no Next.js peer dep. */ +export interface NextLikeRequest { + nextUrl: { pathname: string }; + method: string; + headers: Headers; + clone(): NextLikeRequest; + json(): Promise; +} + +export type NextLikeResponse = Response; + +export interface GovernsNextOptions extends Omit { + /** Only run precheck when this returns true. Default: all POST/PUT/PATCH. */ + matchPath?: (pathname: string) => boolean; + /** Pull input from JSON body. Default: body.input ?? body.prompt ?? body.text. */ + pickInput?: (body: any) => string; + /** Tool name. Default: 'chat'. */ + pickTool?: (req: NextLikeRequest) => string; + /** On precheck error: 'block' returns 503; 'pass' lets through. Default: 'block'. */ + onError?: 'block' | 'pass'; +} + +const DEFAULT_PICK = (body: any): string => + (body && (body.input ?? body.prompt ?? body.text)) ?? ''; + +export function governsNextMiddleware(options: GovernsNextOptions) { + const matchPath = options.matchPath ?? ((p: string) => p.startsWith('/api/')); + const pickInput = options.pickInput ?? DEFAULT_PICK; + const pickTool = options.pickTool ?? ((_r: NextLikeRequest) => 'chat'); + const onError = options.onError ?? 'block'; + + return async function middleware(req: NextLikeRequest): Promise { + if (!matchPath(req.nextUrl.pathname)) return undefined; + const method = req.method.toUpperCase(); + if (method !== 'POST' && method !== 'PUT' && method !== 'PATCH') return undefined; + + // Read JSON body without consuming the original request stream. + let body: any; + try { + body = await req.clone().json(); + } catch { + return undefined; // not JSON; let the route decide + } + + const rawText = pickInput(body); + if (!rawText) return undefined; + + let result: PrecheckResult; + try { + const callOpts: PrecheckCallOptions = { + apiKey: options.apiKey, + baseUrl: options.baseUrl, + tool: pickTool(req), + rawText, + }; + if (options.timeoutMs !== undefined) callOpts.timeoutMs = options.timeoutMs; + if (options.fetchImpl !== undefined) callOpts.fetchImpl = options.fetchImpl; + result = await callPrecheck(callOpts); + } catch (err) { + if (onError === 'pass') return undefined; + return new Response(JSON.stringify({ + error: 'governance_precheck_unavailable', + detail: (err as Error)?.message ?? 'unknown', + }), { status: 503, headers: { 'content-type': 'application/json' } }); + } + + switch (result.decision) { + case 'deny': + return new Response(JSON.stringify({ + error: 'governance_denied', + reasons: result.reasons, + policyId: result.policyId, + }), { + status: 403, + headers: { + 'content-type': 'application/json', + 'x-governs-decision': 'deny', + ...(result.policyId ? { 'x-governs-policy': result.policyId } : {}), + }, + }); + case 'transform': { + // Edge runtime cannot mutate the request body. Surface the + // redacted text via a header — the route handler reads it. + // Edge runtime cannot mutate the request body or headers in + // place. Callers needing rewrite-on-transform should use the + // Express middleware (Node runtime). Here we surface the + // decision in response headers when the route returns. + return undefined; + } + case 'allow': + case 'confirm': + default: + return undefined; + } + }; +} diff --git a/src/middleware/precheck-fetch.ts b/src/middleware/precheck-fetch.ts new file mode 100644 index 0000000..4f7bc45 --- /dev/null +++ b/src/middleware/precheck-fetch.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +/** + * Minimal precheck call via global fetch — no SDK ceremony, no axios. + * + * The middleware modules (`express.ts`, `nextjs.ts`) call this instead of + * instantiating the full PrecheckClient. Keeps the middleware tree-shakeable + * and free of feature-client overhead. + */ + +export type PrecheckDecisionKind = 'allow' | 'transform' | 'deny' | 'confirm'; + +export interface PrecheckResult { + decision: PrecheckDecisionKind; + rawTextOut: string; + reasons: string[]; + policyId?: string; + ts?: number; + /** Raw response body for callers that want everything. */ + raw: Record; +} + +export interface PrecheckCallOptions { + apiKey: string; + /** Base URL of the precheck service (e.g. http://localhost:8082 or https://api.governsai.com) */ + baseUrl: string; + tool: string; + rawText: string; + scope?: string; + userId?: string; + corrId?: string; + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +export class PrecheckHTTPError extends Error { + public readonly status: number; + public readonly body: unknown; + constructor(status: number, message: string, body: unknown) { + super(`precheck HTTP ${status}: ${message}`); + this.status = status; + this.body = body; + } +} + +export async function callPrecheck(opts: PrecheckCallOptions): Promise { + const f = opts.fetchImpl ?? fetch; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 5000); + try { + const payload: Record = { + tool: opts.tool, + raw_text: opts.rawText, + }; + if (opts.scope) payload['scope'] = opts.scope; + if (opts.userId) payload['user_id'] = opts.userId; + if (opts.corrId) payload['corr_id'] = opts.corrId; + + const res = await f(`${opts.baseUrl.replace(/\/$/, '')}/api/v1/precheck`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-governs-key': opts.apiKey, + }, + body: JSON.stringify(payload), + signal: ctrl.signal, + }); + const txt = await res.text(); + let body: any; + try { body = txt ? JSON.parse(txt) : {}; } catch { body = txt; } + + if (!res.ok) { + throw new PrecheckHTTPError(res.status, typeof body === 'string' ? body : (body?.error ?? 'error'), body); + } + return { + decision: (body.decision ?? 'deny') as PrecheckDecisionKind, + rawTextOut: body.raw_text_out ?? body.rawTextOut ?? '', + reasons: body.reasons ?? [], + policyId: body.policy_id ?? body.policyId, + ts: body.ts, + raw: body, + }; + } finally { + clearTimeout(timer); + } +} From bced80422ae661decbd443f1de76ce3f32867598 Mon Sep 17 00:00:00 2001 From: Shaishav Pidadi Date: Thu, 14 May 2026 13:55:36 -0400 Subject: [PATCH 5/6] feat(sdk): re-export Express + Next.js middleware factories from top-level --- src/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/index.ts b/src/index.ts index 186fefe..baf1eb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,14 @@ // Core client export { GovernsAIClient, createClient, createClientFromEnv } from './client'; +// Middleware helpers (import direct via subpaths for tree-shaking). +export { callPrecheck, PrecheckHTTPError } from './middleware/precheck-fetch'; +export type { PrecheckResult, PrecheckCallOptions, PrecheckDecisionKind } from './middleware/precheck-fetch'; +export { governsExpress } from './middleware/express'; +export type { GovernsExpressOptions } from './middleware/express'; +export { governsNextMiddleware } from './middleware/nextjs'; +export type { GovernsNextOptions } from './middleware/nextjs'; + // Feature clients export { PrecheckClient } from './precheck'; export { ConfirmationClient } from './confirmation'; From 70ba7f319aff06f48e37d73ca7c8ec0d1cebf5c3 Mon Sep 17 00:00:00 2001 From: Shaishav Pidadi Date: Fri, 15 May 2026 10:20:37 -0400 Subject: [PATCH 6/6] =?UTF-8?q?chore(release):=20bump=20to=201.0.0-alpha.1?= =?UTF-8?q?6=20=E2=80=94=20includes=20middleware=20exports=20(Express=20+?= =?UTF-8?q?=20Next.js)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cf8dabb..7b68659 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@governs-ai/sdk", - "version": "1.0.0-alpha.14", + "version": "1.0.0-alpha.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@governs-ai/sdk", - "version": "1.0.0-alpha.14", + "version": "1.0.0-alpha.16", "license": "MIT", "dependencies": { "uuid": "^9.0.0" diff --git a/package.json b/package.json index 9bddf6e..a70dece 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@governs-ai/sdk", - "version": "1.0.0-alpha.15", + "version": "1.0.0-alpha.16", "description": "TypeScript SDK for GovernsAI - AI governance platform with unified memory service", "main": "dist/index.js", "types": "dist/index.d.ts",