Skip to content
Merged
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
26 changes: 23 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -15,13 +23,25 @@ jobs:

steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}

- uses: actions/setup-node@v4
with:
node-version: "20"
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
Expand Down
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": "@governs-ai/sdk",
"version": "1.0.0-alpha.14",
"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",
Expand All @@ -9,6 +9,9 @@
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
Expand Down
136 changes: 136 additions & 0 deletions src/__tests__/middleware-express.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> } = { 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 <EMAIL>') 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 <EMAIL>');
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');
});
});
138 changes: 138 additions & 0 deletions src/__tests__/middleware-nextjs.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading