Files
zeekay 03706c03b6 feat(imanage): Hanzo AI for iManage Work — legal DMS panel + OAuth/Work-API proxy
New package @hanzo/imanage: an embedded-app panel over @hanzo/ai for iManage
Work (the dominant legal document management system), mirroring the
packages/procore OAuth + API-proxy + pure-core shape.

- OAuth2 Authorization Code against the iManage Control Center
  (/auth/oauth2/authorize + /token); server-side token exchange + refresh, the
  client secret never reaches the browser.
- Work API v2 wrappers (workspaces, folder children, document profile +
  content, search, gated profile write-back) scoped by customer + library in
  the path, X-Auth-Token auth, envelope/offset-limit pagination. Pure.
- Pure legal document-context assembly with text extraction (binary/OCR out of
  scope, reported honestly) and one truncation-honest windowing algorithm.
- Four AI actions — summarize document, extract key clauses/dates/parties,
  search-and-synthesize a matter, compare a document set — plus freeform ask,
  over a single grounded ask() path.
- Same-origin proxy keeps tokens + secret server-side; NEVER logs document
  content. Legal confidentiality posture documented in the README.
- 121 vitest tests (config/oauth/api/parse/documents/hanzo/actions/panel),
  tsc --noEmit clean, node build.js green.

Registers packages/imanage in pnpm-workspace.yaml (one line).
2026-07-04 02:01:19 -07:00

162 lines
5.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import {
AUTH_HEADER,
DEFAULT_LIMIT,
pageParams,
parsePaging,
unwrap,
libBase,
enc,
listLibraries,
listWorkspaces,
getWorkspace,
listFolderChildren,
getDocument,
getDocumentContent,
updateDocumentProfile,
search,
type Scope,
} from '../src/imanage-api.js';
import { apiBaseUrl } from '../src/config.js';
const scope: Scope = {
apiBase: apiBaseUrl('cloudimanage.com'),
accessToken: 'at-123',
customerId: 1,
libraryId: 'ACTIVE_US',
};
function q(url: string): URLSearchParams {
return new URL(url).searchParams;
}
describe('api: pageParams (offset/limit)', () => {
it('emits nothing for no page', () => {
expect(pageParams(undefined)).toEqual({});
});
it('emits offset + limit when set', () => {
expect(pageParams({ offset: 50, limit: 25 })).toEqual({ offset: '50', limit: '25' });
});
it('clamps limit to the documented max', () => {
expect(pageParams({ limit: 500 }).limit).toBe(String(DEFAULT_LIMIT));
});
it('ignores non-positive values', () => {
expect(pageParams({ offset: 0, limit: -5 })).toEqual({});
});
});
describe('api: libBase + enc', () => {
it('builds the customer/library path prefix', () => {
expect(libBase({ customerId: 1, libraryId: 'ACTIVE_US' })).toBe('/customers/1/libraries/ACTIVE_US');
});
it('encodes ids defensively: a slash cannot traverse the path; iManage’s ! and . pass through as valid path chars', () => {
expect(enc('ACTIVE_US!4567.1')).toBe('ACTIVE_US!4567.1');
expect(enc('a/b')).toBe('a%2Fb');
expect(enc('a b')).toBe('a%20b');
});
});
describe('api: auth scoping', () => {
it('every read carries the X-Auth-Token header (never Authorization: Bearer)', () => {
for (const req of [
listLibraries(scope),
listWorkspaces(scope),
getWorkspace(scope, 'W1'),
listFolderChildren(scope, 'F1'),
getDocument(scope, 'D1'),
getDocumentContent(scope, 'D1'),
search(scope, 'nda'),
]) {
expect(req.headers[AUTH_HEADER]).toBe('at-123');
expect(req.headers.Authorization).toBeUndefined();
}
});
it('AUTH_HEADER is exactly X-Auth-Token', () => {
expect(AUTH_HEADER).toBe('X-Auth-Token');
});
});
describe('api: libraries + workspaces', () => {
it('lists libraries under the customer (no library in path)', () => {
const req = listLibraries(scope);
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries');
});
it('lists workspaces scoped to customer + library with pagination', () => {
const req = listWorkspaces(scope, { offset: 100, limit: 50 });
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/workspaces');
expect(q(req.url).get('offset')).toBe('100');
expect(q(req.url).get('limit')).toBe('50');
});
it('gets one workspace by id', () => {
expect(new URL(getWorkspace(scope, 'W-7').url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/workspaces/W-7');
});
});
describe('api: folder children (browse)', () => {
it('lists a container’s children (folders + documents) with pagination', () => {
const req = listFolderChildren(scope, 'FOLDER!9', { limit: 100 });
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/folders/FOLDER!9/children');
expect(q(req.url).get('limit')).toBe('100');
});
});
describe('api: documents', () => {
it('gets a document profile by id', () => {
const req = getDocument(scope, 'ACTIVE_US!4567.1');
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/documents/ACTIVE_US!4567.1');
});
it('gets document content via /download', () => {
const req = getDocumentContent(scope, 'ACTIVE_US!4567.1');
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/documents/ACTIVE_US!4567.1/download');
});
});
describe('api: updateDocumentProfile (the one write path)', () => {
it('PATCHes a data-wrapped profile patch', () => {
const req = updateDocumentProfile(scope, 'D1', { comment: 'AI summary' });
expect(req.method).toBe('PATCH');
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/documents/D1');
expect(req.headers['Content-Type']).toBe('application/json');
expect(req.headers[AUTH_HEADER]).toBe('at-123');
expect(JSON.parse(req.body)).toEqual({ data: { comment: 'AI summary' } });
});
});
describe('api: search', () => {
it('searches documents across the library with q + pagination', () => {
const req = search(scope, 'master services agreement', { limit: 25, offset: 25 });
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/documents/search');
expect(q(req.url).get('q')).toBe('master services agreement');
expect(q(req.url).get('limit')).toBe('25');
expect(q(req.url).get('offset')).toBe('25');
});
});
describe('api: unwrap envelope', () => {
it('returns the data payload of an envelope', () => {
expect(unwrap({ data: { id: 'x' } })).toEqual({ id: 'x' });
expect(unwrap({ data: [1, 2] })).toEqual([1, 2]);
});
it('returns a bare value unchanged', () => {
expect(unwrap([1, 2])).toEqual([1, 2]);
expect(unwrap({ id: 'y' })).toEqual({ id: 'y' });
});
});
describe('api: parsePaging (body-based, not header)', () => {
it('reads total_count and computes hasMore from offset+returned', () => {
expect(parsePaging({ total_count: 137 }, 50, 0)).toEqual({ total: 137, hasMore: true });
expect(parsePaging({ total_count: 40 }, 40, 0)).toEqual({ total: 40, hasMore: false });
});
it('honours an explicit overflow flag', () => {
expect(parsePaging({ overflow: true }, 50, 0).hasMore).toBe(true);
});
it('honours a next cursor', () => {
expect(parsePaging({ next: 'https://x/page2' }, 50, 0).hasMore).toBe(true);
});
it('reads count as a total fallback and defaults to 0', () => {
expect(parsePaging({ count: 9 }, 9, 0).total).toBe(9);
expect(parsePaging({}, 0, 0)).toEqual({ total: 0, hasMore: false });
});
});