Merge remote-tracking branch 'origin/feat/imanage'

This commit is contained in:
Hanzo Dev
2026-07-04 02:02:14 -07:00
27 changed files with 3747 additions and 0 deletions
+262
View File
@@ -0,0 +1,262 @@
# Hanzo AI for iManage
AI over your iManage Work matter — **summarize a document**, **extract key clauses,
dates & parties** (contract review), **search & synthesize** across a matter, and
**compare a document set**, plus a freeform "ask about this matter" box. An
embedded-app **panel** you host at `imanage.hanzo.ai`, backed by a small **OAuth +
Work-API-proxy service** that keeps the iManage client secret and access tokens
server-side.
iManage Work is the dominant document management system (DMS) in legal and
professional services. This app reads workspaces, folders, documents (metadata +
content), and search results through the iManage Work API and grounds every AI
answer in them.
Built on the published Hanzo SDK:
- **[`@hanzo/ai`](https://www.npmjs.com/package/@hanzo/ai)** — the headless client.
`createAiClient({ baseUrl, token }).chat.completions.create({ model, messages })`
and `.models.list()`. All model calls go to `https://api.hanzo.ai` on `/v1/...`
(never an `/api/` prefix). We import it; we do not reimplement the transport.
- **[`@hanzo/iam`](https://www.npmjs.com/package/@hanzo/iam)** — Hanzo identity /
the `hk-…` API key the panel uses as its gateway bearer.
> **Note on `/api/`.** The `/api/` ban is a **Hanzo gateway** rule (`api.hanzo.ai`
> IS the api host, so paths are `/v1/...`). iManage's **own** Work API path is
> `/api/v2/...` — that is iManage's host, and it is correct to use it verbatim.
---
## Architecture
```
Browser panel (dist/index.html, app.js) Server service (dist/server.js)
───────────────────────────────────── ──────────────────────────────
reads iManage via ──► /proxy/* ──────────► injects the access token
same-origin proxy (cookie) (X-Auth-Token) + refresh, forwards
(never sees an iManage token) to {host}/api/v2/customers/…
└── calls api.hanzo.ai /v1 directly with the pasted hk-… Hanzo key
(@hanzo/ai headless client)
```
- The **panel** never holds an iManage token or the client secret. It reaches
iManage only through the same-origin `/proxy/*` endpoint (with an HttpOnly
session cookie). It talks to `api.hanzo.ai` directly with the user's pasted Hanzo
key. The customer + library scope lives in the request **path**
(`/customers/{cid}/libraries/{lib}/…`), so the proxy just prepends the Work API
base and forwards.
- The **service** is the only place `IMANAGE_CLIENT_SECRET` and the OAuth tokens
exist. It runs the install flow, holds the session, refreshes the token
transparently, and proxies Work API calls.
### Source layout (all pure logic is unit-tested)
| File | Responsibility |
| --- | --- |
| `src/config.ts` | Host resolution (normalize/validate a per-customer iManage host), the `/api/v2` Work API root, the gateway URL, server-secret config (`readServerConfig` fails fast). |
| `src/imanage-oauth.ts` | OAuth2 Authorization-Code shaping: `authorizeUrl`, `tokenExchange`, `refreshExchange`, token parsing + expiry math. Pure. |
| `src/imanage-api.ts` | Work API v2 request wrappers (`listWorkspaces`, `listFolderChildren`, `getDocument`, `getDocumentContent`, `search`, `updateDocumentProfile`) with customer/library scoping + offset/limit pagination, and envelope-aware response parsers. Pure. |
| `src/documents.ts` | Document profile + extracted content + workspace/folder/search JSON → windowed context text + honest truncation, and text extraction (`extractText`, binary/OCR out of scope). Pure. |
| `src/hanzo.ts` | Thin over `@hanzo/ai`: legal-grounded prompt assembly + the single `ask` path + `listModels`. |
| `src/actions.ts` | The four AI actions (id → prompt) over `ask`. One code path to the model. |
| `src/panel.ts` | Browser proxy-client request shaping + launch-context parsing. Pure. |
| `src/app.ts` | The DOM glue for the panel (the one impure browser entry). |
| `src/server.ts` | Node http service: OAuth install/callback + `/config` + `/proxy/*`. Never logs document content. |
---
## 1. Register an app in the iManage Control Center
In the **iManage Control Center** for your customer:
1. Go to **Applications****Add** and register a new OAuth2 application. This
yields a **Client ID** and **Client Secret**.
2. Set the **grant type** to **Authorization Code** and add the **redirect URI**
exactly the URL your service serves the callback at:
```
https://imanage.hanzo.ai/oauth/callback
```
(For local development, add `http://localhost:8793/oauth/callback` too.)
3. Scope is optional for the Authorization-Code app — access is governed by the
signed-in user's own Work permissions. (If your Control Center offers scopes,
grant read access to documents/workspaces and, for the optional write-back,
document profile update.)
4. Note your **iManage host**, your **customer id**, and the **library id(s)** you
work in (e.g. `ACTIVE_US`). The host is per-customer:
| Deployment | Host |
| --- | --- |
| iManage Cloud (US) | `https://cloudimanage.com` |
| Regional cloud | `https://<region>.imanage.work` |
| On-prem Work server | `https://work.<firm>.com` |
The OAuth control-center endpoints and the Work API are served from this host
(set `IMANAGE_API_HOST` if your Work API is fronted separately).
---
## 2. OAuth flow
Standard **Authorization Code** grant (server-side secret):
1. `GET /oauth/install` → 302 to
`https://{host}/auth/oauth2/authorize?response_type=code&client_id=…&redirect_uri=…&state=…`.
2. The user signs in and consents; iManage redirects back to
`GET /oauth/callback?code=…&state=…`.
3. The service verifies `state`, then POSTs to `/auth/oauth2/token` with
`grant_type=authorization_code`, the `code`, the `client_id` + `client_secret`
(**in the body, server-side only**), and the same `redirect_uri`.
4. It opens a session, sets an HttpOnly cookie, and the panel is authenticated.
5. Access tokens are short-lived. The service refreshes with
`grant_type=refresh_token` **before** each proxied call when the token has
expired, storing whatever refresh token comes back.
> In-memory sessions here are for a single instance. For a multi-instance
> deployment behind `hanzoai/ingress`, persist sessions + the OAuth `state` set to
> `hanzoai/kv` (Valkey), and read `IMANAGE_CLIENT_SECRET` from KMS
> (`kms.hanzo.ai`) — never from a committed env file.
---
## 3. iManage Work API
All reads/writes go through `https://{host}/api/v2/...`, scoped by customer +
library in the path. The access token rides in the **`X-Auth-Token`** header
(iManage's Work API convention).
| Capability | Endpoint (relative to `/api/v2`) |
| --- | --- |
| Libraries | `GET /customers/{cid}/libraries` |
| Workspaces | `GET /customers/{cid}/libraries/{lib}/workspaces` |
| Workspace (detail) | `GET …/workspaces/{id}` |
| Folder / workspace contents | `GET …/folders/{folderId}/children` (folders + documents) |
| Document (profile) | `GET …/documents/{id}` |
| Document content | `GET …/documents/{id}/download` |
| Search | `GET …/documents/search?q=…` |
| **Profile write-back** (optional) | `PATCH …/documents/{id}` — body `{ "data": { "comment": "…" } }` |
Responses are wrapped in an iManage `{ "data": … }` envelope; list/search endpoints
paginate with **`offset` + `limit`** and report totals/overflow in the body
(surfaced by `parsePaging`). All of this is normalized at the parser boundary.
### Document content & text extraction
`getDocumentContent` fetches `…/documents/{id}/download`. This app assumes **text or
server-exported text**; `extractText` cleans it for the model. **Binary formats**
(native `.docx`/`.pdf`, images) and **OCR are out of scope** — such content is
reported honestly (`extracted: false` with a reason) and the actions summarize from
the **profile metadata** alone rather than feeding the model garbage.
---
## 4. The summarize-document / extract-clauses flow
1. Connect the app (`/oauth/install`) so the service holds an iManage token.
2. Open the panel, enter your **customer id** + **library id** (or arrive
pre-scoped via the launch-context query / a single-tenant `/config`), and
**Browse** a workspace. Pick a document and **Load doc** — the panel pulls the
document's profile + content through the proxy and assembles a windowed,
truncation-honest context.
3. **Summarize document** — the model summarizes the document type, purpose,
parties, key terms, and anything needing attention, grounded only in the text
(or the profile, when content isn't extractable).
4. **Key clauses, dates & parties** — a contract-review extraction returning four
grounded lists: **Parties**, **Key dates**, **Key clauses** (governing law,
indemnity, limitation of liability, confidentiality, termination — quoting the
operative language), and **Dollar amounts**. Anything not written is reported as
"None found in the text" — never inferred.
5. **Search & synthesize** — enter a query, **Search** the library, and run the
action to synthesize across the result set, attributing each point to its
document.
6. **Compare document set** — over a search result set (or a matter), lists the
material differences side by side.
### Optional write-back (gated + documented)
**Save to comment** writes the current result into the loaded document's **`comment`
profile field** via `PATCH …/documents/{id}` — behind an explicit button **and** a
confirm dialog, targeting only the single loaded document (disabled for a search
result set). It never touches document content. This is the only write path; leave
it unused for a strictly read-only deployment.
---
## Confidentiality posture (legal)
This app handles **confidential, potentially privileged** legal documents. The
design keeps that boundary tight:
- **Tokens + secret are server-side only.** The browser never receives the iManage
access token or the client secret; it reaches iManage exclusively through the
same-origin proxy with an HttpOnly session cookie.
- **No document content is ever logged.** The proxy logs only non-content
bookkeeping (a session id, a status, a target path) — never request/response
bodies, document text, profiles, or search terms.
- **The model is told the material is confidential** and instructed to reveal no
more of the text than the task requires, to never invent clauses/dates/parties,
and that its output is **informational document review, not legal advice** (no
attorney-client relationship).
- **Grounded, truncation-honest context.** Answers are built only from the windowed
records actually sent; when the budget truncates, the context note says so and the
model is told to flag it rather than guess.
- **Least privilege.** Read-and-assist is the default; the one write path is gated
behind an explicit action + confirm and updates only a profile comment.
---
## Build & run
```bash
pnpm install
pnpm --filter @hanzo/imanage build # → dist/ (panel + server)
pnpm --filter @hanzo/imanage test # vitest
pnpm --filter @hanzo/imanage typecheck # tsc --noEmit
# run the service
IMANAGE_CLIENT_ID=… \
IMANAGE_CLIENT_SECRET=… \
IMANAGE_REDIRECT_URI=https://imanage.hanzo.ai/oauth/callback \
IMANAGE_HOST=https://cloudimanage.com \
node packages/imanage/dist/server.js
```
### Required / optional environment (service)
| Var | Notes |
| --- | --- |
| `IMANAGE_CLIENT_ID` | App client id (public). |
| `IMANAGE_CLIENT_SECRET` | **Server only.** From KMS in production. |
| `IMANAGE_REDIRECT_URI` | Must match the app's registered redirect. |
| `IMANAGE_HOST` | The customer's iManage host (auth + Work API). |
| `IMANAGE_API_HOST` | Optional — a distinct Work API host (defaults to `IMANAGE_HOST`). |
| `IMANAGE_CUSTOMER_ID` | Optional — panel scoping default (served at `/config`). |
| `IMANAGE_LIBRARY_ID` | Optional — panel scoping default (served at `/config`). |
| `PORT` | Listen port (default `8793`). |
`readServerConfig` throws on a missing secret or a malformed host — the service
refuses to start rather than pretend it can complete an OAuth exchange.
---
## Deploy over hanzoai/ingress
Host the static panel (`dist/index.html`, `app.js`, `styles.css`) with the
**hanzoai/static** plugin and run `dist/server.js` as a small service, both behind
**hanzoai/ingress** at `imanage.hanzo.ai` (no nginx, no caddy):
- `/` and the static assets → the panel.
- `/oauth/*`, `/config`, `/proxy/*`, `/healthz` → the service.
Secrets come from **KMS** as `KMSSecret`-synced env; sessions from **Valkey**
(`hanzoai/kv`) for multi-instance. Image published to
`ghcr.io/hanzoai/imanage:<tag>` by CI/CD (platform.hanzo.ai / Tekton) — never built
locally.
---
*Routed through `api.hanzo.ai`. Answers are grounded in your iManage document
records — informational document review support, not legal advice, and no
attorney-client relationship is created.*
+86
View File
@@ -0,0 +1,86 @@
// Build @hanzo/imanage into dist/: bundle the web panel (app.ts) as ESM for the
// browser, copy index.html (with its entry script stamped) + styles.css, and
// bundle the OAuth + Work-API-proxy server for Node. No framework — esbuild + Node
// stdlib only. @hanzo/ai is bundled into both outputs (it is the headless client
// the panel and server both call).
//
// node build.js → production build
// node build.js --watch → rebuild on change
// HANZO_IMANAGE_BASE=https://localhost:8443 node build.js → dev base (informational)
//
// Output layout:
// dist/index.html dist/app.js dist/styles.css (panel)
// dist/server.js (service)
//
// The panel is HOSTED over hanzoai/static behind hanzoai/ingress at
// imanage.hanzo.ai; the server runs as a small service (OAuth callback + Work API
// proxy). All model calls go to api.hanzo.ai regardless of the host base.
import esbuild from 'esbuild';
import { rmSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE = (process.env.HANZO_IMANAGE_BASE || 'https://imanage.hanzo.ai').replace(/\/+$/, '');
const watch = process.argv.includes('--watch');
const src = join(__dirname, 'src');
const dist = join(__dirname, 'dist');
async function build() {
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
// Bundle the panel as ESM for the browser.
const panelCtx = await esbuild.context({
entryPoints: { app: join(src, 'app.ts') },
outdir: dist,
bundle: true,
format: 'esm',
platform: 'browser',
target: ['chrome90', 'edge90', 'firefox90', 'safari15'],
sourcemap: true,
minify: !watch,
logLevel: 'info',
});
await panelCtx.rebuild();
// Copy index.html (stamp __ENTRY__ + base) and styles.css.
const html = readFileSync(join(src, 'index.html'), 'utf8')
.split('__ENTRY__').join('app.js')
.replace('</head>', ` <meta name="hanzo:base" content="${BASE}" />\n</head>`);
writeFileSync(join(dist, 'index.html'), html);
copyFileSync(join(src, 'styles.css'), join(dist, 'styles.css'));
// Bundle the server as a Node ESM binary — our pure stdlib code + @hanzo/ai.
const serverCtx = await esbuild.context({
entryPoints: [join(src, 'server.ts')],
outfile: join(dist, 'server.js'),
bundle: true,
format: 'esm',
platform: 'node',
target: ['node18'],
sourcemap: true,
minify: !watch,
banner: { js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);" },
logLevel: 'info',
});
await serverCtx.rebuild();
console.log(`Hanzo AI for iManage built -> dist/ (base ${BASE})`);
console.log(' Panel: dist/index.html · Service: dist/server.js');
if (watch) {
await panelCtx.watch();
await serverCtx.watch();
console.log('watching...');
} else {
await panelCtx.dispose();
await serverCtx.dispose();
}
}
build().catch((e) => {
console.error(e);
process.exit(1);
});
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@hanzo/imanage",
"version": "0.1.0",
"description": "Hanzo AI for iManage Work — AI over your legal document management system: summarize a document, extract key clauses / dates / parties, search-and-synthesize a matter/workspace, and compare a document set. An embedded-app panel plus an OAuth + Work-API-proxy service, built on @hanzo/ai and @hanzo/iam over the api.hanzo.ai gateway.",
"private": true,
"type": "module",
"scripts": {
"build": "node build.js",
"watch": "node build.js --watch",
"start": "node dist/server.js",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hanzo/ai": "^0.2.0",
"@hanzo/iam": "^0.13.2"
},
"devDependencies": {
"@types/node": "^20.14.0",
"esbuild": "^0.25.8",
"typescript": "^5.8.3",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"hanzo",
"imanage",
"legal",
"dms",
"document-management",
"contract-review",
"ai",
"oauth"
],
"author": "Hanzo AI",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/hanzoai/extension.git",
"directory": "packages/imanage"
}
}
+99
View File
@@ -0,0 +1,99 @@
// The AI actions over an iManage document / matter — summarize a document, extract
// key clauses / dates / parties (contract review), search-and-synthesize a matter,
// and compare a document set. Each is a prompt template applied to the windowed
// document context via the single `ask` primitive in hanzo.ts. There is exactly
// ONE code path to the model: an action is (id → prompt), and the panel and any
// server-side caller resolve an id here and call `ask`. No action speaks to the
// gateway directly. (A freeform "ask about the matter" is `ask` directly, not an
// action — it has no fixed prompt.)
import { ask, type AskOptions, type MatterMeta } from './hanzo.js';
import type { DocumentContext } from './documents.js';
// The action catalog. `id` is the stable key the panel's chips use; `label` is the
// button text; `prompt` is the instruction handed to the model as the task, over
// the fenced document records. Prompts are specific and output-shaped (bullets,
// structured lists) so results are consistent and parseable.
export const ACTIONS = {
summarizeDocument: {
label: 'Summarize document',
prompt:
'Summarize the document in these records for a lawyer skimming the matter. ' +
'Cover: what kind of document it is, its purpose, the parties, the key terms ' +
'or obligations, and anything unusual or that needs attention. Reference the ' +
'document name. Use short bullets. No preamble. Ground every statement in the ' +
'text; if the content was not extractable and you only have the profile, say ' +
'so and summarize from the metadata alone.',
},
extractClauses: {
label: 'Key clauses, dates & parties',
prompt:
'Perform a contract-review extraction over the document in these records. ' +
'Return four labelled lists, each grounded strictly in the text: "Parties" ' +
'(every named party and their role); "Key dates" (effective date, term, ' +
'renewal, termination, and any deadline, each with what it governs); "Key ' +
'clauses" (governing law, indemnity, limitation of liability, confidentiality, ' +
'assignment, termination, and any other material clause — quote the operative ' +
'language); and "Dollar amounts / figures" (fees, caps, penalties). If a list ' +
'has no entries in the text, write "None found in the text." Never infer a ' +
'term that is not written.',
},
synthesizeMatter: {
label: 'Search & synthesize',
prompt:
'You are given a set of documents (or search results) from one matter. ' +
'Synthesize an answer to the matter question or, if none is posed, a short ' +
'briefing across the set: what the documents collectively establish, where ' +
'they agree, where they conflict, and what is missing. Attribute each point to ' +
'the document it comes from by name or number. Base every statement on the ' +
'records; if the set is marked truncated, note that the synthesis covers only ' +
'the shown documents.',
},
compareDocuments: {
label: 'Compare document set',
prompt:
'Compare the documents in these records. Produce: a one-line statement of what ' +
'they have in common; then a table-style list of the material differences ' +
'(term, obligation, date, amount, or clause) with each document\'s position ' +
'side by side; then any provision present in one document but absent in ' +
'another. Reference each document by name or number. Compare only what the ' +
'text supports — do not assume a standard or a missing term.',
},
} as const;
// An action id from the catalog.
export type ActionId = keyof typeof ACTIONS;
// isActionId narrows an arbitrary string to a known action id. Boundary guard —
// the panel and the server validate an inbound id here before running it.
export function isActionId(id: string): id is ActionId {
return Object.prototype.hasOwnProperty.call(ACTIONS, id);
}
// actionPrompt resolves an action id to its prompt template. Throws on an unknown
// id (a boundary error, surfaced to the caller) rather than silently running a
// default. Pure.
export function actionPrompt(id: string): string {
if (!isActionId(id)) throw new Error(`Unknown action: ${id}`);
return ACTIONS[id].prompt;
}
// actionList is the ordered catalog for building the UI (chips) — id + label,
// derived from ACTIONS so the panel and the catalog can never drift. Pure.
export function actionList(): Array<{ id: ActionId; label: string }> {
return (Object.keys(ACTIONS) as ActionId[]).map((id) => ({ id, label: ACTIONS[id].label }));
}
// runAction is the single entry point every surface calls: resolve the action's
// prompt and run it over the windowed document context via `ask`. This is the one
// code path from an action id to the model. Async so an unknown id surfaces as a
// rejected promise (not a synchronous throw), giving callers ONE way to handle
// failure: `await`/`.catch`. A gateway error rejects via `ask`.
export async function runAction(
id: string,
ctx: DocumentContext,
meta?: MatterMeta,
opts: AskOptions = {},
): Promise<string> {
return ask(actionPrompt(id), ctx, meta, opts);
}
+367
View File
@@ -0,0 +1,367 @@
// The iManage panel glue: the embedded/linked-app page that opens with a customer
// + library (and optionally a workspace or document) in context and drives the AI
// actions over the matter's live records. All the logic-heavy work (action
// prompts, context windowing, chat shaping, proxy request shaping, text
// extraction, auth) lives in its own tested modules; this file binds them to the
// DOM. It is the one impure, browser-only entry point.
//
// Data flow: the panel reads iManage ONLY through the same-origin server proxy
// (createProxyClient), which holds the OAuth token server-side. It loads a
// document's profile + extracted content (or a search result set), assembles a
// windowed context (buildContext), and runs an action or a freeform question
// against api.hanzo.ai with the pasted Hanzo key. The optional profile write-back
// (save the summary into the document's comment) posts back through the same
// proxy, behind an explicit button + confirm.
import { actionList, isActionId, runAction } from './actions.js';
import { ask as askHanzo, listModels, type MatterMeta } from './hanzo.js';
import {
buildContext,
contextNote,
documentSection,
documentIndexSection,
workspaceSection,
searchResultSection,
type DocumentContext,
} from './documents.js';
import { DEFAULT_MODEL } from './config.js';
import { getApiKey, setApiKey, hasApiKey, bearer, validateKey } from './auth.js';
import { createProxyClient, parseLaunchContext, type ProxyClient } from './panel.js';
import type { Workspace, DocumentEntry } from './imanage-api.js';
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
let controller: AbortController | null = null;
window.addEventListener('DOMContentLoaded', () => {
const launch = parseLaunchContext(window.location.search);
const customerEl = $<HTMLInputElement>('customer');
const libraryEl = $<HTMLInputElement>('library');
const workspaceEl = $<HTMLSelectElement>('workspace');
const browseBtn = $<HTMLButtonElement>('browse');
const documentEl = $<HTMLSelectElement>('document');
const loadBtn = $<HTMLButtonElement>('load');
const searchEl = $<HTMLInputElement>('search');
const searchBtn = $<HTMLButtonElement>('searchbtn');
const recordsEl = $('records');
const outputEl = $<HTMLTextAreaElement>('output');
const statusEl = $('status');
const modelEl = $<HTMLSelectElement>('model');
const chipRow = $('chips');
const runBtn = $<HTMLButtonElement>('run');
const stopBtn = $<HTMLButtonElement>('stop');
const promptEl = $<HTMLTextAreaElement>('prompt');
const apiKeyEl = $<HTMLInputElement>('apikey');
const saveKeyBtn = $<HTMLButtonElement>('savekey');
const authHint = $('authhint');
const saveBtn = $<HTMLButtonElement>('save');
customerEl.value = launch.customerId;
libraryEl.value = launch.libraryId;
apiKeyEl.value = getApiKey();
reflectAuth();
void populateModels();
void loadConfigDefaults();
// Loaded state: the context the actions run over, the matter meta, and the
// document we're scoped to (for the profile write-back). A search result set has
// no single document, so write-back is disabled then.
let ctx: DocumentContext | null = null;
let meta: MatterMeta = {};
let currentDocumentId = '';
// Action chips — derived from the catalog so UI and logic never drift.
for (const a of actionList()) {
const b = document.createElement('button');
b.className = 'chip';
b.textContent = a.label;
b.dataset.action = a.id;
b.onclick = () => void run(a.id);
chipRow.appendChild(b);
}
// client builds a proxy client scoped to the current customer + library. Rebuilt
// on demand so a scope change takes effect immediately.
function client(): ProxyClient {
return createProxyClient({ customerId: customerEl.value.trim(), libraryId: libraryEl.value.trim() });
}
browseBtn.onclick = () => void browseWorkspaces();
workspaceEl.onchange = () => void browseDocuments();
documentEl.onchange = () => {
currentDocumentId = documentEl.value;
};
loadBtn.onclick = () => void loadDocument();
searchBtn.onclick = () => void runSearch();
runBtn.onclick = () => {
const prompt = promptEl.value.trim();
if (prompt) void ask(prompt);
};
stopBtn.onclick = () => controller?.abort();
saveBtn.onclick = () => void saveToProfile();
saveKeyBtn.onclick = async () => {
const key = apiKeyEl.value.trim();
setStatus('Checking key…');
try {
const models = await validateKey(key);
setApiKey(key);
fillModels(models);
reflectAuth();
setStatus('Key saved.', 'ok');
} catch (e: any) {
setStatus(e?.message || 'Key rejected.', 'error');
}
};
// If we launched already scoped, browse workspaces; if a workspace or document
// was named, drill straight in.
if (launch.customerId && launch.libraryId) {
void browseWorkspaces().then(() => {
if (launch.workspaceId) {
workspaceEl.value = launch.workspaceId;
void browseDocuments().then(() => {
if (launch.documentId) {
documentEl.value = launch.documentId;
currentDocumentId = launch.documentId;
void loadDocument();
}
});
}
});
}
// loadConfigDefaults pre-fills customer/library from a single-tenant deployment's
// /config when the launch context didn't supply them. Best-effort.
async function loadConfigDefaults(): Promise<void> {
if (customerEl.value && libraryEl.value) return;
try {
const resp = await fetch('/config', { credentials: 'include' });
if (!resp.ok) return;
const cfg = await resp.json();
if (!customerEl.value && cfg.customerId) customerEl.value = String(cfg.customerId);
if (!libraryEl.value && cfg.libraryId) libraryEl.value = String(cfg.libraryId);
} catch {
/* no /config — the user enters the scope manually */
}
}
// browseWorkspaces fills the workspace picker from the proxy for the current
// scope. An empty scope (or an auth failure) leaves it empty with a hint.
async function browseWorkspaces(): Promise<void> {
if (!requireScope()) return;
setStatus('Loading workspaces…');
try {
const { items } = await client().listWorkspaces({ limit: 100 });
fillWorkspaces(items);
setStatus(items.length ? `Loaded ${items.length} workspaces.` : 'No workspaces visible.', items.length ? 'ok' : 'warn');
if (items.length) await browseDocuments();
} catch (e: any) {
setStatus(e?.message || 'Could not load workspaces — is the app connected? (Connect on imanage.hanzo.ai)', 'error');
}
}
// browseDocuments lists the selected workspace's documents into the document
// picker (a workspace is a container, so its id is a valid folder id here).
async function browseDocuments(): Promise<void> {
const workspaceId = workspaceEl.value;
if (!workspaceId) return;
setStatus('Loading documents…');
try {
const { items } = await client().listFolderChildren(workspaceId, { limit: 100 });
fillDocuments(items.documents);
const ws = workspaces.find((w) => w.id === workspaceId);
meta = ws ? { workspaceName: ws.name, matterNumber: ws.matter, client: ws.client } : {};
setStatus(
`${items.documents.length} documents · ${items.folders.length} folders in ${ws?.name || workspaceId}.`,
'ok',
);
} catch (e: any) {
setStatus(e?.message || 'Could not load documents.', 'error');
}
}
// loadDocument pulls the selected document's profile + content and assembles a
// single-document context the actions run over.
async function loadDocument(): Promise<void> {
const documentId = documentEl.value || currentDocumentId;
if (!documentId) {
setStatus('Pick a document first.', 'warn');
return;
}
currentDocumentId = documentId;
setStatus('Loading document…');
const c = client();
try {
const [doc, extracted] = await Promise.all([c.getDocument(documentId), c.getDocumentContent(documentId).catch(() => null)]);
const text = extracted ?? { text: '', extracted: false, reason: 'Content could not be retrieved.' };
ctx = buildContext([documentSection(doc, text)]);
meta = { ...meta, workspaceName: meta.workspaceName };
recordsEl.textContent = `${doc.name}${doc.extension ? '.' + doc.extension : ''}${text.extracted ? '' : ' (profile only — content not extractable)'}`;
saveBtn.disabled = false;
saveBtn.title = `Save the result into the comment field of ${doc.name}`;
setStatus(contextNote(ctx));
} catch (e: any) {
setStatus(e?.message || 'Could not load the document.', 'error');
}
}
// runSearch runs a document search across the library and builds a result-set
// context for the synthesize / compare actions (index only, not full content).
async function runSearch(): Promise<void> {
const query = searchEl.value.trim();
if (!requireScope()) return;
if (!query) {
setStatus('Enter a search query.', 'warn');
return;
}
setStatus(`Searching for “${query}”…`);
try {
const { items, paging } = await client().search(query, { limit: 50 });
ctx = buildContext([searchResultSection(items)]);
currentDocumentId = ''; // a result set has no single document
saveBtn.disabled = true;
recordsEl.textContent = `${items.length}${paging.hasMore ? '+' : ''} documents matched “${query}”.`;
setStatus(items.length ? contextNote(ctx) : 'No documents matched.', items.length ? '' : 'warn');
} catch (e: any) {
setStatus(e?.message || 'Search failed.', 'error');
}
}
// run executes one of the named actions over the loaded context.
async function run(actionId: string): Promise<void> {
if (!isActionId(actionId)) return;
await execute((c, m, opts) => runAction(actionId, c, m, opts));
}
// ask executes a freeform question over the loaded context.
async function ask(prompt: string): Promise<void> {
await execute((c, m, opts) => askHanzo(prompt, c, m, opts));
}
// execute is the shared runner: require a loaded context, call the model, show
// the result. Both the chips and freeform ask funnel through here (one path).
async function execute(
call: (
c: DocumentContext,
m: MatterMeta,
opts: { token: string; model: string; signal: AbortSignal },
) => Promise<string>,
): Promise<void> {
if (!ctx || ctx.totalBlocks === 0) {
setStatus('Load a document or run a search first.', 'warn');
return;
}
controller?.abort();
controller = new AbortController();
setBusy(true);
setStatus(contextNote(ctx));
outputEl.value = '';
try {
const text = await call(ctx, meta, {
token: bearer(),
model: modelEl.value || DEFAULT_MODEL,
signal: controller.signal,
});
outputEl.value = text;
setStatus('Done.', 'ok');
} catch (e: any) {
if (e?.name === 'AbortError') setStatus('Stopped.');
else setStatus(e?.message || 'Request failed.', 'error');
} finally {
setBusy(false);
}
}
// saveToProfile writes the current output back to iManage as the loaded
// document's comment — the explicit, documented, gated write-back. Guarded: never
// posts an empty body and always confirms which document it targets.
async function saveToProfile(): Promise<void> {
const body = outputEl.value.trim();
if (!currentDocumentId) return setStatus('Load a single document to save a comment to.', 'warn');
if (!body) return setStatus('Nothing to save — run an action first.', 'warn');
if (!confirm(`Save this into the comment field of the loaded document? This updates its iManage profile.`)) return;
setBusy(true);
setStatus('Saving to iManage profile…');
try {
await client().updateDocumentProfile(currentDocumentId, { comment: body.slice(0, 4000) });
setStatus('Saved to the document comment.', 'ok');
} catch (e: any) {
setStatus(e?.message || 'Save failed.', 'error');
} finally {
setBusy(false);
}
}
// ---- small DOM helpers ----------------------------------------------------
function requireScope(): boolean {
if (!customerEl.value.trim() || !libraryEl.value.trim()) {
setStatus('Enter your iManage customer id and library id.', 'warn');
return false;
}
return true;
}
let workspaces: Workspace[] = [];
function fillWorkspaces(items: Workspace[]): void {
workspaces = items;
workspaceEl.innerHTML = '';
for (const w of items) {
const opt = document.createElement('option');
opt.value = w.id;
opt.textContent = w.name || w.id;
workspaceEl.appendChild(opt);
}
}
function fillDocuments(items: DocumentEntry[]): void {
documentEl.innerHTML = '';
for (const d of items) {
const opt = document.createElement('option');
opt.value = d.id;
opt.textContent = `${d.name}${d.extension ? '.' + d.extension : ''}`;
documentEl.appendChild(opt);
}
currentDocumentId = documentEl.value || '';
}
async function populateModels(): Promise<void> {
try {
fillModels(await listModels({ token: bearer() }));
} catch {
fillModels([DEFAULT_MODEL]);
}
}
function fillModels(ids: string[]): void {
const list = ids.length ? ids : [DEFAULT_MODEL];
modelEl.innerHTML = '';
for (const id of list) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = id;
modelEl.appendChild(opt);
}
if (list.includes(DEFAULT_MODEL)) modelEl.value = DEFAULT_MODEL;
}
function reflectAuth(): void {
authHint.textContent = hasApiKey()
? 'Using your saved Hanzo key.'
: 'No key saved — using public models. Paste an hk-… key for your org models.';
}
function setBusy(b: boolean): void {
runBtn.disabled = b;
stopBtn.disabled = !b;
loadBtn.disabled = b;
browseBtn.disabled = b;
searchBtn.disabled = b;
for (const c of Array.from(chipRow.querySelectorAll('button'))) (c as HTMLButtonElement).disabled = b;
saveBtn.disabled = b || !currentDocumentId;
}
function setStatus(msg: string, kind: '' | 'ok' | 'warn' | 'error' = ''): void {
statusEl.textContent = msg;
statusEl.className = `status${kind ? ' ' + kind : ''}`;
}
});
+56
View File
@@ -0,0 +1,56 @@
// Auth for the web panel: the zero-setup pasted-key path for the Hanzo gateway.
// The iManage panel is a static web page (iframe), so the Hanzo credential lives in
// localStorage and is validated by a real /v1/models call — a key that can't list
// models is rejected before it's saved, so the user learns at paste time, not at
// first action. Mirrors @hanzo/procore exactly (one way to hold a key).
//
// The iManage access token (for reading workspaces/documents) is a SEPARATE
// credential minted server-side by the OAuth flow and held by server.ts; the panel
// reaches iManage only through the server proxy (with its session cookie), so it
// never holds the iManage token or secret. This module is only the Hanzo-gateway
// bearer.
import { APIKEY_STORAGE_KEY, pickBearer } from './config.js';
import { listModels } from './hanzo.js';
export function getApiKey(): string {
try {
return localStorage.getItem(APIKEY_STORAGE_KEY) || '';
} catch {
return '';
}
}
export function setApiKey(key: string): void {
try {
const k = key.trim();
if (k) localStorage.setItem(APIKEY_STORAGE_KEY, k);
else localStorage.removeItem(APIKEY_STORAGE_KEY);
} catch {
/* private-mode / storage disabled — the in-memory key still works this session */
}
}
export function clearApiKey(): void {
setApiKey('');
}
export function hasApiKey(): boolean {
return !!getApiKey();
}
// bearer is the credential the chat call sends: the pasted key (or empty for
// anonymous public models). When Hanzo OAuth lands it slots in as the second
// argument to pickBearer with no change to callers.
export function bearer(): string {
return pickBearer(getApiKey(), '');
}
// validateKey confirms a pasted key actually works by listing models with it.
// Returns the models on success so the caller populates the picker in one
// round-trip; throws with the gateway's message on failure.
export async function validateKey(key: string): Promise<string[]> {
const k = key.trim();
if (!k) throw new Error('Enter a Hanzo API key (hk-…).');
return listModels({ token: k });
}
+201
View File
@@ -0,0 +1,201 @@
// iManage config — the customer's iManage Work host (auth/control-center + Work
// API), the Work API version segment, the api.hanzo.ai model gateway, and the
// server-side secret set (iManage OAuth). Endpoints and the bearer choice mirror
// @hanzo/procore / @hanzo/clio so the productivity suite stays DRY; the
// legal-DMS-specific pieces (document-context windowing, the AI actions) live in
// documents.ts / hanzo.ts / actions.ts, not here.
//
// iManage is NOT a fixed sandbox↔production pair like Procore: every customer runs
// on their own host (iManage Cloud `cloudimanage.com`, a regional cloud host, or
// an on-prem Work server). So the host is a configured value, not an enum — the
// OAuth + Work API URLs are all derived from it.
// ---- Hanzo model gateway --------------------------------------------------
// Where the Hanzo model gateway lives. `@hanzo/ai` defaults here too. /v1 only,
// never an /api/ prefix (api.hanzo.ai IS the api host). NOTE: the /api/ ban is a
// Hanzo-gateway rule; iManage's OWN Work API path IS `/api/v2/...` and that is
// correct — it is iManage's host, not ours.
export const HANZO_API_BASE_URL = 'https://api.hanzo.ai';
// Default model. A Zen model (qwen3+). Overridable per-request via the picker;
// the gateway routes it.
export const DEFAULT_MODEL = 'zen5';
// Public IAM origin that mints Hanzo user tokens, and the OAuth client id an
// inbound Hanzo token is audienced to (owner-scoping validation via @hanzo/iam).
export const DEFAULT_IAM_SERVER_URL = 'https://hanzo.id';
export const DEFAULT_IAM_CLIENT_ID = 'hanzo-imanage';
// localStorage key for the pasted Hanzo API key (`hk-…`) in the web panel. The
// panel is a static web page (iframe), not a host with roamingSettings, so the
// zero-setup credential lives in localStorage — same as @hanzo/procore.
export const APIKEY_STORAGE_KEY = 'hanzo.imanage.apiKey';
// Document text budget. A legal document (or a matter's document set) is far
// larger than a model window and must not be sent whole. This caps the characters
// of iManage document text we attach to any one request — chosen so it fits
// comfortably inside a modern context window alongside the reply, and is honest
// rather than optimal (we truncate visibly, never drop silently).
export const DOCUMENT_CHAR_BUDGET = 60_000;
// pickBearer chooses the credential to send to the Hanzo gateway: a pasted API
// key wins over an OAuth token (an explicit key is a deliberate override), else
// the token, else empty (anonymous — the gateway still serves public models).
// Pure — unit-tested.
export function pickBearer(apiKey: string, oauthToken: string): string {
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
}
// chatCompletionsURL / modelsURL — the model gateway endpoints.
export function chatCompletionsURL(): string {
return `${HANZO_API_BASE_URL}/v1/chat/completions`;
}
export function modelsURL(): string {
return `${HANZO_API_BASE_URL}/v1/models`;
}
// ---- iManage Work hosts + Work API ----------------------------------------
//
// One iManage customer has (usually) ONE host serving both the OAuth
// control-center endpoints (/auth/oauth2/authorize + /token) and the Work API
// (/api/v2/...). Some deployments front the Work API on a separate host, so we
// allow an optional API host that defaults to the auth host. We never hard-code a
// single host inline — every URL is built from a normalized host string, so
// pointing at a different customer's iManage is one env value.
//
// iManage Cloud (US): https://cloudimanage.com
// regional cloud: https://<region>.imanage.work
// on-prem Work server: https://work.<firm>.com
//
// Docs: docs.imanage.com (Work API) and the iManage Control Center OAuth2 guide.
export interface ImanageHosts {
/** Auth / control-center host — /auth/oauth2/authorize and /token live here. */
auth: string;
/** Work API host — /api/v2/... lives here. */
api: string;
}
// normalizeHost coerces a host value into a clean `https://host[:port]` origin:
// adds https:// when a bare host is given, and strips any trailing slash / path so
// the endpoint builders can append their own paths cleanly. Pure — unit-tested.
export function normalizeHost(host: string): string {
const trimmed = (host || '').trim();
if (trimmed === '') return '';
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
return new URL(withScheme).origin;
} catch {
// Fall back to a best-effort strip so a malformed value never throws here;
// isHttpUrl is the boundary guard that rejects it in readServerConfig.
return withScheme.replace(/\/+$/, '');
}
}
// isHttpUrl narrows an arbitrary string to a usable http(s) origin. Boundary guard
// used by readServerConfig so the service refuses to start against a nonsense host.
export function isHttpUrl(v: string | undefined): boolean {
if (!v) return false;
// A value that already carries a scheme keeps it (so ftp:// etc. is rejected);
// a bare host is assumed https.
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(v);
try {
const u = new URL(hasScheme ? v : `https://${v}`);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
// hosts resolves the auth + api host set from a configured host (and an optional
// distinct API host). Pure — the request wrappers take the resolved strings.
export function hosts(host: string, apiHost?: string): ImanageHosts {
const auth = normalizeHost(host);
const api = apiHost && apiHost.trim() ? normalizeHost(apiHost) : auth;
return { auth, api };
}
// The Work API version segment. v2 is current for the resources we read
// (workspaces, folders, documents, search); we never bump to a speculative v3 —
// one version, forward only.
export const WORK_API_VERSION = 'v2';
// apiBaseUrl turns an API host into the Work API root: `${api}/api/v2`. Every
// iManage Work API call is built on top of this. Pure — the request wrappers in
// imanage-api.ts take this string. (The `/api/` here is iManage's own path.)
export function apiBaseUrl(apiHost: string): string {
return `${normalizeHost(apiHost)}/api/${WORK_API_VERSION}`;
}
// The OAuth scopes the app requests. iManage's Authorization Code grant treats
// scope as optional (access is governed by the user's Work permissions and the
// app registration in Control Center); we send it only when non-empty, leaving
// room for future fine-grained scopes without a code change.
export const OAUTH_SCOPES = [] as const;
// ---- Server-side configuration (iManage OAuth) ----------------------------
//
// These are read from the environment by src/server.ts. They NEVER reach the
// browser bundle: the client id + host are non-secret, but the client secret is
// server-only and is validated to be present before the server will start
// (readServerConfig throws on a missing secret). This is the ONLY place the secret
// exists.
//
// customerId / libraryId are the (non-secret) scoping defaults a single-tenant
// deployment fixes so the panel opens pre-scoped; they are optional (the panel can
// also take them from its launch-context query or manual fields).
export interface ServerConfig {
/** iManage OAuth client id (public). */
imanageClientId: string;
/** iManage OAuth client secret (SERVER ONLY — token exchange + refresh). */
imanageClientSecret: string;
/** OAuth redirect registered on the app (e.g. https://imanage.hanzo.ai/oauth/callback). */
imanageRedirectUri: string;
/** The customer's iManage host (auth control-center + Work API unless apiHost set). */
imanageHost: string;
/** Optional distinct Work API host; defaults to imanageHost. */
imanageApiHost?: string;
/** Non-secret default customer id the panel opens scoped to (optional). */
customerId: string;
/** Non-secret default library id the panel opens scoped to (optional). */
libraryId: string;
/** Listen port. */
port: number;
}
// readServerConfig fails fast (throws) if a required iManage secret/host is
// missing or malformed — a server that cannot exchange OAuth codes or reach a Work
// host must not pretend to start. Pure given an env map, so it is unit-tested
// without touching process.env.
export function readServerConfig(env: Record<string, string | undefined>): ServerConfig {
const imanageClientId = env.IMANAGE_CLIENT_ID;
const imanageClientSecret = env.IMANAGE_CLIENT_SECRET;
const imanageRedirectUri = env.IMANAGE_REDIRECT_URI;
const imanageHost = env.IMANAGE_HOST;
const missing: string[] = [];
if (!imanageClientId) missing.push('IMANAGE_CLIENT_ID');
if (!imanageClientSecret) missing.push('IMANAGE_CLIENT_SECRET');
if (!imanageRedirectUri) missing.push('IMANAGE_REDIRECT_URI');
if (!imanageHost) missing.push('IMANAGE_HOST');
if (missing.length > 0) {
throw new Error(`Missing required environment: ${missing.join(', ')}`);
}
if (!isHttpUrl(imanageHost)) {
throw new Error(`IMANAGE_HOST is not a valid http(s) host: ${imanageHost}`);
}
if (env.IMANAGE_API_HOST && !isHttpUrl(env.IMANAGE_API_HOST)) {
throw new Error(`IMANAGE_API_HOST is not a valid http(s) host: ${env.IMANAGE_API_HOST}`);
}
return {
imanageClientId: imanageClientId!,
imanageClientSecret: imanageClientSecret!,
imanageRedirectUri: imanageRedirectUri!,
imanageHost: normalizeHost(imanageHost!),
imanageApiHost: env.IMANAGE_API_HOST ? normalizeHost(env.IMANAGE_API_HOST) : undefined,
customerId: env.IMANAGE_CUSTOMER_ID?.trim() || '',
libraryId: env.IMANAGE_LIBRARY_ID?.trim() || '',
port: Number(env.PORT) || 8793,
};
}
+227
View File
@@ -0,0 +1,227 @@
// Document-context assembly — PURE, host-agnostic, fully unit-testable. Turns the
// typed iManage records (document profiles, extracted content, workspaces, folder
// listings, search results) into the plain text an AI action reads, windowed to a
// character budget so a large legal document (or matter set) never overflows the
// model or gets sent silently truncated.
//
// There is ONE windowing contract, identical in spirit to @hanzo/procore's project
// windowing: render ordered blocks, walk them in order, stop at the budget, always
// include at least the first block, and report `truncated` honestly. The only
// legal-DMS-specific part is how a record (or a document's content) renders to a
// block.
import type { DocumentEntry, FolderEntry, Workspace } from './imanage-api.js';
import { DOCUMENT_CHAR_BUDGET } from './config.js';
// ---- Text extraction ------------------------------------------------------
//
// getDocumentContent returns the stored bytes. We assume text or exported text;
// binary formats (native .docx/.pdf without server-side export, images) are OUT OF
// SCOPE and reported honestly rather than fed as garbage. looksBinary is a cheap
// heuristic: a NUL byte, or a high share of non-printable control characters, means
// we cannot treat it as text here.
// looksBinary reports whether a fetched content string is (probably) not plain
// text. Pure — samples the head so it stays cheap on large documents.
export function looksBinary(raw: string): boolean {
if (raw.length === 0) return false;
const sample = raw.slice(0, 4096);
let control = 0;
for (let i = 0; i < sample.length; i++) {
const c = sample.charCodeAt(i);
if (c === 0) return true; // a NUL byte is a reliable binary tell
// Allow tab (9), LF (10), CR (13); count other C0 controls + DEL as binary.
if ((c < 32 && c !== 9 && c !== 10 && c !== 13) || c === 127) control++;
}
return control / sample.length > 0.15;
}
// The result of extracting text from fetched content: the text (empty when not
// extractable) and an honest reason when nothing usable came out.
export interface ExtractedText {
text: string;
extracted: boolean;
reason?: string;
}
// extractText normalizes fetched content to context text. Binary / empty content
// yields extracted:false with a reason the caller surfaces (never fabricated
// text). Whitespace is collapsed lightly so the model sees clean prose. Pure.
export function extractText(raw: string, name?: string): ExtractedText {
const label = name ? ` (${name})` : '';
if (!raw || raw.trim().length === 0) {
return { text: '', extracted: false, reason: `The document${label} has no extractable text content.` };
}
if (looksBinary(raw)) {
return {
text: '',
extracted: false,
reason: `The document${label} is a binary format (native or image) — text extraction / OCR is out of scope here; summarize from the profile metadata only.`,
};
}
const text = raw.replace(/\r\n/g, '\n').replace(/[ \t]+\n/g, '\n').trim();
return { text, extracted: true };
}
// ---- Record → text block --------------------------------------------------
// renderDocumentMeta turns a document profile into a labelled metadata block.
// Empty fields are omitted rather than rendered as blank lines. Pure.
export function renderDocumentMeta(doc: DocumentEntry): string {
const head = `Document: ${doc.name || doc.id}`.trim();
const meta: string[] = [];
if (doc.extension) meta.push(`Type: .${doc.extension}`);
if (doc.class) meta.push(`Class: ${doc.class}`);
if (doc.author) meta.push(`Author: ${doc.author}`);
if (doc.version !== '' && doc.version !== undefined) meta.push(`Version: ${doc.version}`);
if (doc.editDate) meta.push(`Edited: ${doc.editDate}`);
const lines = [head];
if (meta.length) lines.push(meta.join(' · '));
if (doc.comment) lines.push(`Comment: ${doc.comment.trim()}`);
return lines.join('\n');
}
// renderWorkspace turns one workspace (matter container) into a labelled block.
export function renderWorkspace(w: Workspace): string {
const head = `Workspace: ${w.name || w.id}`.trim();
const meta: string[] = [];
if (w.client) meta.push(`Client: ${w.client}`);
if (w.matter) meta.push(`Matter: ${w.matter}`);
const lines = [meta.length ? `${head}\n${meta.join(' · ')}` : head];
if (w.description) lines.push(w.description.trim());
return lines.join('\n');
}
// renderDocumentLine turns one document entry into a single labelled line (for a
// folder listing or a search result set — the index, not the content).
export function renderDocumentLine(doc: DocumentEntry): string {
const ext = doc.extension ? `.${doc.extension}` : '';
const who = doc.author ? `${doc.author}` : '';
const when = doc.editDate ? ` (${doc.editDate})` : '';
return `Doc: ${doc.name || doc.id}${ext}${who}${when}`;
}
// renderFolderLine turns one folder entry into a single labelled line.
export function renderFolderLine(f: FolderEntry): string {
return `Folder: ${f.name || f.id}`;
}
// contentBlocks splits a document's extracted text into paragraph blocks so the
// windowing algorithm can include as much as fits and cut on a paragraph boundary
// rather than mid-sentence. Pure.
export function contentBlocks(text: string): string[] {
return text
.split(/\n{2,}/)
.map((b) => b.trim())
.filter((b) => b.length > 0);
}
// ---- Windowing to a budget ------------------------------------------------
// A named group of rendered blocks — a section of the context (e.g. "Document
// profile", "Contract text", "Search results"). The assembler concatenates
// sections in the order given and windows the whole thing to the budget.
export interface Section {
title: string;
blocks: string[];
}
// The windowed document context: the rendered text, how many blocks were available
// vs included, and whether anything was dropped (so the prompt and UI can say so
// honestly).
export interface DocumentContext {
text: string;
totalBlocks: number;
includedBlocks: number;
truncated: boolean;
}
// buildContext concatenates sections IN ORDER and caps the rendered text at
// `budget` characters. It walks blocks in order (across sections) and stops when
// adding the next block would exceed the budget — always including at least the
// first block (hard-cut to the budget if that one block alone is over).
// `truncated` is true whenever not every available block made it in. Pure and
// total: deterministic, no I/O. This is the ONE windowing algorithm for the
// package — the same "fit ordered text to a budget" contract as @hanzo/procore.
export function buildContext(
sections: Section[],
budget: number = DOCUMENT_CHAR_BUDGET,
): DocumentContext {
const totalBlocks = sections.reduce((n, s) => n + s.blocks.length, 0);
if (totalBlocks === 0) {
return { text: '', totalBlocks: 0, includedBlocks: 0, truncated: false };
}
const parts: string[] = [];
let used = 0;
let included = 0;
let hardCut = false;
outer: for (const section of sections) {
if (section.blocks.length === 0) continue;
// The section header is charged to the first block that fits under it.
let headerPending = `## ${section.title}\n`;
for (const block of section.blocks) {
const prefix = parts.length === 0 ? '' : '\n\n';
const addition = prefix + headerPending + block;
if (used + addition.length > budget) {
if (parts.length === 0) {
// First block alone overflows — hard-cut it to the budget so we always
// send something rather than an empty context.
const solo = (headerPending + block).slice(0, budget);
parts.push(solo);
used = solo.length;
included = 1;
hardCut = true;
}
break outer;
}
parts.push(addition);
used += addition.length;
included += 1;
headerPending = ''; // header only precedes the first block of the section
}
}
const truncated = included < totalBlocks || hardCut;
return { text: parts.join(''), totalBlocks, includedBlocks: included, truncated };
}
// contextNote is the one honest sentence prepended to the document text so the
// model (and, echoed in the panel, the user) knows the scope of what it sees.
// Never claim the whole document/matter was sent when it wasn't. Pure.
export function contextNote(ctx: DocumentContext): string {
if (ctx.totalBlocks === 0) return 'No document content or records were available.';
return ctx.truncated
? `Document context: ${ctx.includedBlocks} of ${ctx.totalBlocks} sections (truncated to fit — answer only from what is shown and say so if the omitted part is needed).`
: `Document context: all ${ctx.totalBlocks} section${ctx.totalBlocks === 1 ? '' : 's'}.`;
}
// ---- Convenience section builders -----------------------------------------
//
// These turn typed records into a Section, so a caller (server or panel) assembles
// a context in a couple of lines. Kept here (pure) so the same section shapes feed
// both surfaces and the tests.
// documentSection assembles ONE document: its profile metadata plus its extracted
// content (paragraph-blocked). When content was not extractable, the reason rides
// as the section's single content block so the model knows to work from metadata.
export function documentSection(doc: DocumentEntry, extracted: ExtractedText, title = 'Document'): Section {
const blocks = [renderDocumentMeta(doc)];
if (extracted.extracted && extracted.text) blocks.push(...contentBlocks(extracted.text));
else if (extracted.reason) blocks.push(`[${extracted.reason}]`);
return { title, blocks };
}
export function documentIndexSection(docs: DocumentEntry[], title = 'Documents'): Section {
return { title, blocks: docs.map(renderDocumentLine) };
}
export function folderSection(folders: FolderEntry[], title = 'Folders'): Section {
return { title, blocks: folders.map(renderFolderLine) };
}
export function workspaceSection(workspaces: Workspace[], title = 'Workspaces'): Section {
return { title, blocks: workspaces.map(renderWorkspace) };
}
export function searchResultSection(docs: DocumentEntry[], title = 'Search results'): Section {
return { title, blocks: docs.map(renderDocumentLine) };
}
+150
View File
@@ -0,0 +1,150 @@
// The Hanzo call and its request/response shaping over a DOCUMENT / MATTER context
// — thin over the published `@hanzo/ai` headless client, host-agnostic, and fully
// unit-testable (no iManage SDK, no DOM). The panel and the server are the glue
// that read a document's profile + content and hand them here. Speaks the
// OpenAI-compatible /v1 wire protocol the whole Hanzo suite uses (identical to
// @hanzo/procore) so the gateway sees one shape from every surface.
//
// @hanzo/ai@^0.2.0 IS the headless client: createAiClient({ baseUrl, token })
// → .chat.completions.create({ model, messages }) + .models.list(). We import it
// and do NOT reimplement the transport. This module adds only the document-aware
// prompt assembly + the single `ask` primitive the actions layer on.
import {
createAiClient,
type AiClient,
type ChatCompletion,
type ChatCompletionMessage,
} from '@hanzo/ai';
import { DEFAULT_MODEL, HANZO_API_BASE_URL } from './config.js';
import { contextNote, type DocumentContext } from './documents.js';
// SYSTEM_PROMPT grounds every answer in the iManage records. It forbids inventing
// clauses, dates, parties, or terms that nobody wrote (the failure mode that makes
// a legal assistant dangerous), asks it to cite the document name/number, and
// stays honest about truncated context. It states plainly that the output is
// informational and NOT legal advice — a review tool that pretends otherwise is a
// liability — and reinforces the confidentiality posture (work only from what is
// shown; do not restate more of the privileged text than the task needs).
export const SYSTEM_PROMPT =
'You are Hanzo AI, an assistant that helps legal and professional teams work ' +
'with documents in their iManage Work matter — contracts, correspondence, ' +
'pleadings, and memos. Work ONLY from the document content and profile records ' +
'provided below — never invent clauses, dates, parties, defined terms, dollar ' +
'amounts, or facts that are not supported by the text. Cite the document name or ' +
'number when the text carries it, and quote exact contract language when a clause ' +
'or figure matters. Be precise, neutral, and concise. If the records are marked ' +
'truncated and a complete answer needs the omitted part, say so plainly rather ' +
'than guessing. These are confidential, potentially privileged materials: reveal ' +
'no more of the text than the task requires. You provide informational document ' +
'review support, NOT legal advice, and you do not create an attorney-client ' +
'relationship.';
// ---- Prompt assembly ------------------------------------------------------
// Optional structured context about the matter (workspace name, matter number,
// client) the iManage records supply. Attached as a short header above the records
// so answers can reference the matter without the model guessing.
export interface MatterMeta {
workspaceName?: string;
matterNumber?: string;
client?: string;
}
function matterHeader(meta: MatterMeta | undefined): string {
if (!meta) return '';
const parts: string[] = [];
if (meta.workspaceName) parts.push(`Workspace: ${meta.workspaceName}`);
if (meta.matterNumber) parts.push(`Matter #: ${meta.matterNumber}`);
if (meta.client) parts.push(`Client: ${meta.client}`);
return parts.length > 0 ? parts.join('\n') + '\n\n' : '';
}
// buildMessages turns a task (the user's question, or one of the action prompts)
// plus the windowed document context + optional matter metadata into the
// OpenAI-compatible message list. The records are fenced so the model treats them
// as data, not instructions, and the honest context note rides inside the user
// turn so it is never lost. Pure — unit-tested.
export function buildMessages(
task: string,
ctx: DocumentContext,
meta?: MatterMeta,
): ChatCompletionMessage[] {
const system: ChatCompletionMessage = { role: 'system', content: SYSTEM_PROMPT };
const header = matterHeader(meta);
const user: ChatCompletionMessage = {
role: 'user',
content:
`${header}${contextNote(ctx)}\n\n` +
`---- document records ----\n${ctx.text}\n---- end document records ----\n\n` +
`---- task ----\n${task}`,
};
return [system, user];
}
// extractContent pulls the assistant text out of a @hanzo/ai ChatCompletion,
// tolerating the string-or-parts content shape the OpenAI schema allows. Throws on
// empty content so callers surface the real gateway state. Pure — unit-tested.
export function extractContent(res: ChatCompletion): string {
const msg = res?.choices?.[0]?.message;
const content = msg?.content;
let text = '';
if (typeof content === 'string') {
text = content;
} else if (Array.isArray(content)) {
text = content.map((part) => (part?.type === 'text' ? part.text : '')).join('');
}
if (text.length === 0) throw new Error('Hanzo API returned no content');
return text;
}
// ---- The single call path -------------------------------------------------
export interface AskOptions {
model?: string;
temperature?: number;
token?: string;
baseURL?: string;
/** Injected client (tests). Defaults to a real createAiClient. */
client?: AiClient;
signal?: AbortSignal;
}
// client resolves the @hanzo/ai client for a call: an injected one (tests) or a
// fresh createAiClient pointed at the gateway with the caller's bearer.
function client(opts: AskOptions): AiClient {
return (
opts.client ??
createAiClient({ token: opts.token, baseUrl: opts.baseURL ?? HANZO_API_BASE_URL })
);
}
// ask runs ONE non-streaming completion over a document/matter context and returns
// the answer. This is the single path from every iManage surface to the model —
// the actions, a freeform question, and any server-side call all funnel here. token
// may be empty (the gateway serves anonymous/limited models).
export async function ask(
task: string,
ctx: DocumentContext,
meta: MatterMeta | undefined,
opts: AskOptions = {},
): Promise<string> {
const res = await client(opts).chat.completions.create(
{
model: opts.model ?? DEFAULT_MODEL,
messages: buildMessages(task, ctx, meta),
stream: false,
...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}),
},
{ signal: opts.signal },
);
return extractContent(res);
}
// listModels returns the model ids the caller may route to, from /v1/models via the
// headless client. The endpoint is org-scoped by the bearer; an empty token lists
// public models.
export async function listModels(opts: AskOptions = {}): Promise<string[]> {
const models = await client(opts).models.list({ signal: opts.signal });
return models.map((m) => m.id);
}
+375
View File
@@ -0,0 +1,375 @@
// iManage Work API v2 — pure request shaping + response parsing. Every function
// returns a PreparedRequest (url + headers [+ body]) or parses a response body;
// none opens a socket, so the whole API surface is unit-testable without a
// network. server.ts is the thin glue that fetches these shapes with the OAuth
// access token.
//
// The Work API root is `${api}/api/v2` (see config.apiBaseUrl). Everything is
// scoped by a CUSTOMER and a LIBRARY in the path:
// /customers/{customer_id}/libraries/{library_id}/...
// (customer = the iManage account/tenant; library = a document database, e.g.
// `ACTIVE_US`). Document ids are opaque strings, typically `{library}!{num}.{ver}`
// e.g. `ACTIVE_US!4567.1`.
//
// Auth: the OAuth access token is sent as the `X-Auth-Token` header — iManage's
// Work API convention (NOT `Authorization: Bearer`). We keep that in ONE place.
//
// Docs: docs.imanage.com — Work API v2 (workspaces, folders, documents, search).
// A prepared GET: url + headers a single fetch needs.
export interface PreparedGet {
url: string;
headers: Record<string, string>;
}
// A prepared write (POST/PATCH): adds the JSON body and Content-Type.
export interface PreparedWrite extends PreparedGet {
method: 'POST' | 'PATCH';
body: string;
}
// The auth header name iManage's Work API expects the access token in.
export const AUTH_HEADER = 'X-Auth-Token';
// headers builds the auth header set common to every request. iManage takes the
// OAuth access token in X-Auth-Token; the customer/library scope is in the PATH,
// not a header, so nothing else is common.
function headers(accessToken: string): Record<string, string> {
return { [AUTH_HEADER]: accessToken };
}
// jsonHeaders adds Content-Type for a write body on top of the auth header.
function jsonHeaders(accessToken: string): Record<string, string> {
return { ...headers(accessToken), 'Content-Type': 'application/json' };
}
// The scope every request carries: the Work API root, the access token, and which
// customer + library. Grouping them keeps every wrapper's signature small and
// makes the two-level scoping impossible to forget.
export interface Scope {
apiBase: string;
accessToken: string;
customerId: string | number;
libraryId: string;
}
// Pagination controls. iManage's Work API paginates with `offset` + `limit` query
// params and reports total/overflow in the response BODY (not a header) — see
// parsePaging. We attach these on list/search endpoints.
export interface Page {
offset?: number;
limit?: number;
}
// DEFAULT_LIMIT is iManage's practical page size; the Work API caps a page, so we
// clamp to a documented-safe maximum to minimise round-trips without over-asking.
export const DEFAULT_LIMIT = 100;
// pageParams renders pagination into query pairs, clamping `limit` to the maximum
// and dropping non-positive values. Only emits params that are set, so an
// unpaginated call stays clean. Pure — unit-tested.
export function pageParams(page: Page | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!page) return out;
if (typeof page.offset === 'number' && page.offset > 0) out.offset = String(Math.floor(page.offset));
if (typeof page.limit === 'number' && page.limit > 0) {
out.limit = String(Math.min(Math.floor(page.limit), DEFAULT_LIMIT));
}
return out;
}
// buildUrl joins the Work API root + a customer/library-scoped path and appends a
// query string, dropping undefined/empty values. One place builds every URL so
// scoping/pagination params are applied consistently. Pure.
function buildUrl(scope: Scope, path: string, query: Record<string, string> = {}): string {
const base = `${scope.apiBase.replace(/\/+$/, '')}${libBase(scope)}${path}`;
const q = new URLSearchParams();
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== '') q.set(k, v);
}
const qs = q.toString();
return qs ? `${base}?${qs}` : base;
}
// libBase is the customer/library path prefix every scoped resource hangs off.
// Exported so the panel's proxy client builds the exact same relative paths.
export function libBase(scope: Pick<Scope, 'customerId' | 'libraryId'>): string {
return `/customers/${enc(scope.customerId)}/libraries/${enc(scope.libraryId)}`;
}
// enc encodes a single path segment. iManage document ids carry `!` and `.`
// (`ACTIVE_US!4567.1`); we encode defensively so a stray value can never break —
// or traverse — the path.
export function enc(segment: number | string): string {
return encodeURIComponent(String(segment));
}
// ---- Libraries ------------------------------------------------------------
// listLibraries — the document libraries the customer exposes. Customer-scoped
// (no library in the path). Used to discover a library id when one is not fixed.
export function listLibraries(scope: Scope): PreparedGet {
return {
url: `${scope.apiBase.replace(/\/+$/, '')}/customers/${enc(scope.customerId)}/libraries`,
headers: headers(scope.accessToken),
};
}
// ---- Workspaces -----------------------------------------------------------
// listWorkspaces — the workspaces (matters / client-matter containers) in the
// library the caller can see. The panel's picker uses this to choose which
// workspace to run against.
export function listWorkspaces(scope: Scope, page?: Page): PreparedGet {
return {
url: buildUrl(scope, '/workspaces', pageParams(page)),
headers: headers(scope.accessToken),
};
}
// getWorkspace — one workspace's profile (name, client, matter, description).
export function getWorkspace(scope: Scope, workspaceId: string): PreparedGet {
return {
url: buildUrl(scope, `/workspaces/${enc(workspaceId)}`),
headers: headers(scope.accessToken),
};
}
// ---- Folders + documents (browse) -----------------------------------------
// listFolderChildren — the contents of a container (a workspace or a folder): its
// subfolders AND its documents, in one paginated call. iManage's `/children`
// endpoint returns a mixed collection with a `type`/`wstype` discriminator per
// entry; parseFolderChildren splits it into folders + documents. This is the ONE
// browse endpoint (a workspace id is a valid container id here).
export function listFolderChildren(scope: Scope, folderId: string, page?: Page): PreparedGet {
return {
url: buildUrl(scope, `/folders/${enc(folderId)}/children`, pageParams(page)),
headers: headers(scope.accessToken),
};
}
// ---- Documents ------------------------------------------------------------
// getDocument — one document's full profile (metadata): name, extension, size,
// author/operator, class/type, doc number, version, dates, comment, and the
// workspace/database it lives in. This is what summarize / extract-clauses read
// alongside the extracted content.
export function getDocument(scope: Scope, documentId: string): PreparedGet {
return {
url: buildUrl(scope, `/documents/${enc(documentId)}`),
headers: headers(scope.accessToken),
};
}
// getDocumentContent — the document's content bytes for text extraction. iManage's
// `/download` returns the stored file; we assume text or exported text (binary /
// OCR is out of scope, handled honestly in documents.extractText). Returns a
// PreparedGet whose response the caller reads as text.
export function getDocumentContent(scope: Scope, documentId: string): PreparedGet {
return {
url: buildUrl(scope, `/documents/${enc(documentId)}/download`),
headers: headers(scope.accessToken),
};
}
// updateDocumentProfile — the ONE write path (behind an explicit, gated action):
// PATCH a document's profile fields (e.g. write an AI summary into `comment`).
// Never touches content. iManage nests the profile under a `data` key.
export function updateDocumentProfile(
scope: Scope,
documentId: string,
patch: Record<string, unknown>,
): PreparedWrite {
return {
method: 'PATCH',
url: buildUrl(scope, `/documents/${enc(documentId)}`),
headers: jsonHeaders(scope.accessToken),
body: JSON.stringify({ data: patch }),
};
}
// ---- Search ---------------------------------------------------------------
// search — full-text / profile search for documents across the library. iManage's
// document search takes the query as `q` (anywhere) plus pagination; parseSearch
// reads the result set (same document shape as a folder listing). Project-wide
// find used by the search-and-synthesize action.
export function search(scope: Scope, query: string, page?: Page): PreparedGet {
return {
url: buildUrl(scope, '/documents/search', { q: query, ...pageParams(page) }),
headers: headers(scope.accessToken),
};
}
// ---- Response envelope + paging -------------------------------------------
//
// iManage Work API v2 wraps its payload under a top-level `data` key and reports
// paging in the body (`total_count`/`count` + an `overflow` flag or a `next`
// cursor). We normalize both at this boundary so nothing downstream touches the
// wire, and tolerate bare/undecorated shapes defensively.
// unwrap returns the `data` payload of an iManage envelope, or the value itself
// when it is already unwrapped. Pure.
export function unwrap(body: any): any {
if (body && typeof body === 'object' && 'data' in body) return body.data;
return body;
}
// asArray coerces an unwrapped payload to an array (iManage list responses are
// `{ data: [...] }`; a stray object or null becomes []).
function asArray(body: any): any[] {
const d = unwrap(body);
return Array.isArray(d) ? d : [];
}
// The parsed paging state of a list/search response: the total available (when the
// server reports it), and whether more pages exist. `hasMore` reads iManage's
// `overflow`/`next` signal, falling back to offset+returned<total.
export interface Paging {
total: number;
hasMore: boolean;
}
export function parsePaging(body: any, returned: number, offset = 0): Paging {
const totalRaw = body?.total_count ?? body?.count ?? body?.total;
const total = Number.isFinite(Number(totalRaw)) && Number(totalRaw) >= 0 ? Number(totalRaw) : 0;
const overflow = body?.overflow === true || body?.data?.overflow === true;
const hasNext = typeof body?.next === 'string' && body.next.length > 0;
const hasMore = overflow || hasNext || (total > 0 && offset + returned < total);
return { total, hasMore };
}
// ---- Typed records --------------------------------------------------------
// One library as it appears in listLibraries.
export interface Library {
id: string;
name: string;
}
export function parseLibraries(data: any): Library[] {
return asArray(data)
.map((l: any) => ({ id: String(l?.id ?? ''), name: String(l?.name ?? l?.id ?? '') }))
.filter((l: Library) => l.id.length > 0);
}
// One workspace (matter container) profile.
export interface Workspace {
id: string;
name: string;
description: string;
client: string;
matter: string;
database: string;
}
function parseWorkspace(w: any): Workspace {
return {
id: String(w?.id ?? ''),
name: String(w?.name ?? ''),
description: String(w?.description ?? ''),
client: String(w?.client ?? w?.custom1 ?? ''),
matter: String(w?.matter ?? w?.custom2 ?? ''),
database: String(w?.database ?? w?.wsdb ?? ''),
};
}
export function parseWorkspaces(data: any): Workspace[] {
return asArray(data).map(parseWorkspace).filter((w: Workspace) => w.id.length > 0);
}
export function parseWorkspaceDetail(data: any): Workspace {
return parseWorkspace(unwrap(data) ?? {});
}
// One document entry as surfaced from a listing/search/profile — the metadata we
// render into context. `id` is iManage's opaque document id.
export interface DocumentEntry {
id: string;
name: string;
extension: string;
size: number;
author: string;
class: string;
type: string;
version: string | number;
editDate: string;
comment: string;
}
// isDocumentRow decides whether a `/children` row is a document (vs a folder).
// iManage marks folders with wstype/type `folder`/`workspace`; a document is marked
// `document` or (untyped) carries an extension / nested document object. An untyped
// row with neither is treated as a folder (a container), never guessed as a doc.
function isDocumentRow(r: any): boolean {
const t = String(r?.type ?? r?.wstype ?? '').toLowerCase();
if (t === 'document') return true;
if (t === 'folder' || t === 'workspace') return false;
return r?.extension !== undefined || (r?.document && typeof r.document === 'object');
}
function parseDocumentRow(r: any): DocumentEntry {
const d = r?.document && typeof r.document === 'object' ? r.document : r;
return {
id: String(d?.id ?? ''),
name: String(d?.name ?? d?.document_name ?? ''),
extension: String(d?.extension ?? '').toLowerCase(),
size: Number(d?.size ?? d?.file_size ?? 0) || 0,
author: String(d?.author_description ?? d?.author ?? ''),
class: String(d?.class_description ?? d?.class ?? ''),
// `type_description` only: the raw `type` in a /children row is the folder-vs-
// document discriminator (isDocumentRow), NOT the document's profile type.
type: String(d?.type_description ?? ''),
version: d?.version ?? '',
editDate: String(d?.edit_date ?? d?.edit_profile_date ?? d?.update_date ?? ''),
comment: String(d?.comment ?? ''),
};
}
// parseDocument reads a getDocument body (envelope-wrapped) into a DocumentEntry.
export function parseDocument(data: any): DocumentEntry {
return parseDocumentRow(unwrap(data) ?? {});
}
// One folder entry from a `/children` listing.
export interface FolderEntry {
id: string;
name: string;
}
function parseFolderRow(r: any): FolderEntry {
return { id: String(r?.id ?? ''), name: String(r?.name ?? '') };
}
// The split contents of a container: its subfolders and its documents. iManage's
// `/children` returns both in one mixed array; we separate them so the context
// assembly and the picker treat each uniformly.
export interface FolderChildren {
folders: FolderEntry[];
documents: DocumentEntry[];
}
export function parseFolderChildren(data: any): FolderChildren {
const rows = asArray(data);
const folders: FolderEntry[] = [];
const documents: DocumentEntry[] = [];
for (const r of rows) {
if (isDocumentRow(r)) {
const d = parseDocumentRow(r);
if (d.id.length > 0) documents.push(d);
} else {
const f = parseFolderRow(r);
if (f.id.length > 0) folders.push(f);
}
}
return { folders, documents };
}
// parseSearchResults reads a document-search body into DocumentEntry rows (the
// search result set is the same document shape as a listing).
export function parseSearchResults(data: any): DocumentEntry[] {
return asArray(data)
.map(parseDocumentRow)
.filter((d: DocumentEntry) => d.id.length > 0);
}
+147
View File
@@ -0,0 +1,147 @@
// iManage Work OAuth 2.0 (Authorization Code Grant) — pure request shaping. The
// client secret is held by the server (server.ts) and never reaches the browser;
// these functions build the exact URL/body of each OAuth call so the wire shape is
// unit-testable without a network round-trip. The token exchange and refresh are
// each one fetch in the server.
//
// iManage's OAuth2 endpoints live on the customer's Control Center host:
// authorize: https://{host}/auth/oauth2/authorize
// token: https://{host}/auth/oauth2/token
// The token endpoint takes application/x-www-form-urlencoded with client_id +
// client_secret IN THE BODY (not HTTP Basic) — the standard OAuth2 web-server
// shape. See the iManage Control Center OAuth2 guide.
import { hosts } from './config.js';
// authorizeUrl is where the install flow sends the user's browser to grant the
// app. `state` is the CSRF token the server generates and re-checks on callback.
// iManage takes response_type=code + client_id + redirect_uri; scope is optional
// (governed by the app registration + the user's Work permissions), so it is only
// appended when non-empty.
export function authorizeUrl(args: {
host: string;
clientId: string;
redirectUri: string;
scopes?: readonly string[];
state: string;
}): string {
const params: Record<string, string> = {
response_type: 'code',
client_id: args.clientId,
redirect_uri: args.redirectUri,
state: args.state,
};
if (args.scopes && args.scopes.length > 0) params.scope = args.scopes.join(' ');
const q = new URLSearchParams(params);
return `${hosts(args.host).auth}/auth/oauth2/authorize?${q.toString()}`;
}
// A prepared HTTP request: the pieces a single fetch needs. Pure output so a test
// asserts the form body without opening a socket. iManage's token endpoint takes
// application/x-www-form-urlencoded.
export interface PreparedRequest {
url: string;
headers: Record<string, string>;
body: string;
}
// tokenUrl is the OAuth token endpoint for a host.
export function tokenUrl(host: string): string {
return `${hosts(host).auth}/auth/oauth2/token`;
}
// tokenExchange builds the code→token request against the host's
// /auth/oauth2/token. grant_type=authorization_code with the code, the app's
// credentials, and the SAME redirect_uri that was used on /authorize (iManage
// requires it to match). The secret rides only in this server-side body.
export function tokenExchange(args: {
host: string;
clientId: string;
clientSecret: string;
code: string;
redirectUri: string;
}): PreparedRequest {
return {
url: tokenUrl(args.host),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: args.code,
client_id: args.clientId,
client_secret: args.clientSecret,
redirect_uri: args.redirectUri,
}).toString(),
};
}
// refreshExchange builds the refresh-token→token request. iManage access tokens
// are short-lived; the refresh may return a new refresh token, so the server
// stores whatever refresh_token comes back after every refresh (defensive against
// rotation).
export function refreshExchange(args: {
host: string;
clientId: string;
clientSecret: string;
refreshToken: string;
}): PreparedRequest {
return {
url: tokenUrl(args.host),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: args.refreshToken,
client_id: args.clientId,
client_secret: args.clientSecret,
}).toString(),
};
}
// The token response we care about. iManage returns access_token (+ refresh_token,
// expires_in, token_type, scope). parseTokenResponse validates the one field we
// must have and surfaces iManage's own error otherwise.
export interface ImanageTokenSet {
access_token: string;
refresh_token?: string;
expires_in?: number;
token_type?: string;
scope?: string;
}
export function parseTokenResponse(data: any): ImanageTokenSet {
if (!data || typeof data !== 'object') throw new Error('empty token response');
if (data.error) {
const reason = data.error_description || data.message || data.error;
throw new Error(`iManage OAuth error: ${reason}`);
}
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
throw new Error('iManage OAuth response missing access_token');
}
return {
access_token: data.access_token,
refresh_token: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
expires_in: typeof data.expires_in === 'number' ? data.expires_in : undefined,
token_type: typeof data.token_type === 'string' ? data.token_type : undefined,
scope: typeof data.scope === 'string' ? data.scope : undefined,
};
}
// tokenExpiresAt computes the absolute epoch-seconds expiry from a token set,
// applying a safety skew so a request is never sent with a token about to expire
// mid-flight. iManage does not return an issued-at, so the base is the wall-clock
// `now` at which the token was minted (the caller passes it). Pure — testable.
export function tokenExpiresAt(token: ImanageTokenSet, mintedAt: number, skewSeconds = 60): number {
const ttl = typeof token.expires_in === 'number' ? token.expires_in : 0;
return mintedAt + ttl - skewSeconds;
}
// isExpired reports whether a token minted at `mintedAt` is at/past its (skew-
// adjusted) expiry as of `now`. The server checks this before each API call and
// refreshes when true.
export function isExpired(
token: ImanageTokenSet,
mintedAt: number,
now: number,
skewSeconds = 60,
): boolean {
return now >= tokenExpiresAt(token, mintedAt, skewSeconds);
}
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo AI for iManage</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header>
<span class="title">Hanzo AI</span>
<select id="model" aria-label="Model"></select>
<span class="host">iManage Work</span>
</header>
<div class="row scope">
<input id="customer" placeholder="Customer id" aria-label="iManage customer id" />
<input id="library" placeholder="Library id" aria-label="iManage library id" />
<button id="browse" class="secondary">Browse</button>
</div>
<div class="row scope">
<select id="workspace" aria-label="Workspace"></select>
<select id="document" aria-label="Document"></select>
<button id="load" class="secondary">Load doc</button>
</div>
<div class="row scope">
<input id="search" placeholder="Search the library for documents…" aria-label="Document search" />
<button id="searchbtn" class="secondary">Search</button>
</div>
<div class="records" id="records"></div>
<!-- One-click actions over the loaded document / result set. Chips built from the catalog. -->
<div class="chips" id="chips"></div>
<label for="prompt">Ask about this matter</label>
<textarea id="prompt" placeholder="e.g. What is the governing law and termination notice period? · Does this NDA survive termination? · Summarize the parties' obligations."></textarea>
<div class="row">
<button id="run">Ask Hanzo</button>
<button id="stop" class="secondary" disabled>Stop</button>
<span class="spacer"></span>
<button id="save" class="secondary" disabled title="Load a document to save a comment to">Save to comment</button>
</div>
<details>
<summary>Hanzo API key</summary>
<div class="row">
<input id="apikey" type="password" placeholder="hk-…" autocomplete="off" />
<button id="savekey" class="secondary">Save</button>
</div>
<div id="authhint" class="hint"></div>
</details>
<div class="status" id="status"></div>
<label for="output">Result</label>
<textarea id="output" placeholder="Hanzo's analysis appears here."></textarea>
<footer>Routed through api.hanzo.ai · grounded in your iManage document records · confidential — informational document review, not legal advice.</footer>
<script type="module" src="__ENTRY__"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
// Public surface of @hanzo/imanage: the pure, host-agnostic modules. The browser
// panel (app.ts) and the Node service (server.ts) are entry points, not re-exported
// here. A consumer embedding the document-analysis pipeline in their own service
// imports from here.
export * from './config.js';
export * from './imanage-oauth.js';
export * from './imanage-api.js';
export * from './documents.js';
export * from './hanzo.js';
export * from './actions.js';
+167
View File
@@ -0,0 +1,167 @@
// Browser-side panel helpers — PURE where it counts (launch-context parsing +
// proxy-request shaping), so the impure DOM wiring in app.ts stays thin and the
// logic is unit-tested. The panel reaches iManage ONLY through the server proxy
// (same-origin /proxy/*), which injects the OAuth token (X-Auth-Token) and
// refreshes it; the browser never holds an iManage token.
//
// The customer + library scope lives in the PATH the browser builds (iManage's
// paths are /customers/{cid}/libraries/{lib}/...), so the proxy simply prepends the
// Work API base and forwards. libBase + enc are reused from imanage-api so the
// panel and the server-side wrappers build the exact same relative paths.
import {
parseWorkspaces,
parseWorkspaceDetail,
parseFolderChildren,
parseDocument,
parseSearchResults,
parsePaging,
pageParams,
libBase,
enc,
type Workspace,
type DocumentEntry,
type FolderChildren,
type Paging,
type Page,
} from './imanage-api.js';
import { extractText, type ExtractedText } from './documents.js';
// The iManage launch context: when a linked/embedded app opens from Work it can
// pass the customer, library, and (optionally) a workspace or document in the URL
// query. The panel reads them so it opens already scoped. All optional — without
// them the user enters the customer/library and picks from the workspace list.
export interface LaunchContext {
customerId: string;
libraryId: string;
workspaceId: string;
documentId: string;
}
// parseLaunchContext reads the scope ids from a location search string, accepting
// snake_case and camelCase keys. Pure — unit-tested with plain strings.
export function parseLaunchContext(search: string): LaunchContext {
const q = new URLSearchParams(search);
const get = (a: string, b: string) => q.get(a) ?? q.get(b) ?? '';
return {
customerId: get('customer_id', 'customerId'),
libraryId: get('library_id', 'libraryId'),
workspaceId: get('workspace_id', 'workspaceId'),
documentId: get('document_id', 'documentId'),
};
}
// The panel's iManage client: reads through the same-origin server proxy. Every
// call builds a customer/library-scoped path and rides the session cookie
// (credentials: 'include'). Pure over an injected fetch + base, so request shaping
// is unit-testable.
export interface ProxyClient {
listWorkspaces(page?: Page): Promise<{ items: Workspace[]; paging: Paging }>;
getWorkspace(workspaceId: string): Promise<Workspace>;
listFolderChildren(folderId: string, page?: Page): Promise<{ items: FolderChildren; paging: Paging }>;
getDocument(documentId: string): Promise<DocumentEntry>;
getDocumentContent(documentId: string): Promise<ExtractedText>;
search(query: string, page?: Page): Promise<{ items: DocumentEntry[]; paging: Paging }>;
updateDocumentProfile(documentId: string, patch: Record<string, unknown>): Promise<void>;
}
export interface ProxyClientOptions {
/** Customer + library scope — woven into every request path. */
customerId: string;
libraryId: string;
/** Proxy base (defaults to same-origin ''). The REST path is appended after /proxy. */
base?: string;
/** Injected fetch (tests). Defaults to global fetch. */
fetch?: typeof fetch;
}
// proxyUrl builds a same-origin proxy URL for a REST path + query. Normalizes the
// base (drops a trailing slash) so this is the ONE place base+path joining happens.
// Kept small and pure so tests assert the exact shape
// (…/proxy/customers/1/libraries/ACTIVE_US/workspaces?limit=…).
export function proxyUrl(base: string, restPath: string, query: Record<string, string> = {}): string {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== '') q.set(k, v);
const qs = q.toString();
return `${base.replace(/\/+$/, '')}/proxy${restPath}${qs ? `?${qs}` : ''}`;
}
// createProxyClient returns a ProxyClient bound to a customer/library scope. The
// cookie credentials are attached once here so every method stays a one-liner and
// the scoping is impossible to forget.
export function createProxyClient(opts: ProxyClientOptions): ProxyClient {
const base = opts.base ?? ''; // proxyUrl normalizes the trailing slash
const doFetch = opts.fetch ?? fetch;
const scope = { customerId: opts.customerId, libraryId: opts.libraryId };
const lib = libBase(scope); // /customers/{cid}/libraries/{lib}
async function getJson(
restPath: string,
query: Record<string, string>,
offset = 0,
): Promise<{ data: any; paging: Paging }> {
const resp = await doFetch(proxyUrl(base, restPath, query), { credentials: 'include' });
if (!resp.ok) throw new Error(await proxyError(resp));
const body = await resp.json();
const returned = Array.isArray(body?.data) ? body.data.length : Array.isArray(body) ? body.length : 0;
return { data: body, paging: parsePaging(body, returned, offset) };
}
return {
async listWorkspaces(page?: Page) {
const { data, paging } = await getJson(`${lib}/workspaces`, pageParams(page), page?.offset ?? 0);
return { items: parseWorkspaces(data), paging };
},
async getWorkspace(workspaceId: string) {
const { data } = await getJson(`${lib}/workspaces/${enc(workspaceId)}`, {});
return parseWorkspaceDetail(data);
},
async listFolderChildren(folderId: string, page?: Page) {
const { data, paging } = await getJson(
`${lib}/folders/${enc(folderId)}/children`,
pageParams(page),
page?.offset ?? 0,
);
return { items: parseFolderChildren(data), paging };
},
async getDocument(documentId: string) {
const { data } = await getJson(`${lib}/documents/${enc(documentId)}`, {});
return parseDocument(data);
},
async getDocumentContent(documentId: string) {
const resp = await doFetch(proxyUrl(base, `${lib}/documents/${enc(documentId)}/download`), {
credentials: 'include',
});
if (!resp.ok) throw new Error(await proxyError(resp));
return extractText(await resp.text());
},
async search(query: string, page?: Page) {
const { data, paging } = await getJson(
`${lib}/documents/search`,
{ q: query, ...pageParams(page) },
page?.offset ?? 0,
);
return { items: parseSearchResults(data), paging };
},
async updateDocumentProfile(documentId: string, patch: Record<string, unknown>) {
const resp = await doFetch(proxyUrl(base, `${lib}/documents/${enc(documentId)}`), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ data: patch }),
});
if (!resp.ok) throw new Error(await proxyError(resp));
},
};
}
// proxyError extracts a readable message from a non-2xx proxy response.
async function proxyError(resp: Response): Promise<string> {
const text = await resp.text().catch(() => '');
try {
const j = JSON.parse(text);
return `iManage proxy ${resp.status}: ${j?.error || j?.message || text.slice(0, 200)}`;
} catch {
return `iManage proxy ${resp.status}: ${text.slice(0, 200) || 'request failed'}`;
}
}
+259
View File
@@ -0,0 +1,259 @@
// The iManage install + Work-API-proxy service. This is the ONLY place the iManage
// client secret and the OAuth tokens exist — the secret is read from the
// environment (never bundled, never sent to the browser); tokens are minted by the
// OAuth flow and kept server-side, refreshed transparently on expiry. It:
//
// GET /oauth/install → redirect the user to iManage's OAuth consent
// GET /oauth/callback → exchange the code for a token, open a session, and
// hand the browser a session cookie
// GET /config → the non-secret scoping defaults (customer/library)
// for the panel to pre-fill; NO secrets, NO tokens
// ALL /proxy/* → forward a browser request to the iManage Work API
// with the session's access token (X-Auth-Token),
// refreshing it first if it has expired. The browser
// never sees the token or the secret.
// GET /healthz → readiness
//
// CONFIDENTIALITY: this service handles privileged legal documents. It NEVER logs
// request or response BODIES (document content, profiles, search terms) — only
// non-content bookkeeping (a session id, a status code, a target path). Tokens and
// the client secret stay server-side.
//
// It is a dependency-free Node http handler built on the pure modules (config,
// imanage-oauth, imanage-api) so it is deployable behind hanzoai/ingress as a small
// service at imanage.hanzo.ai.
//
// node dist/server.js (after build.js bundles it)
//
// Required environment (see config.readServerConfig):
// IMANAGE_CLIENT_ID, IMANAGE_CLIENT_SECRET, IMANAGE_REDIRECT_URI, IMANAGE_HOST
// IMANAGE_API_HOST (optional; defaults to IMANAGE_HOST)
// IMANAGE_CUSTOMER_ID, IMANAGE_LIBRARY_ID (optional panel defaults)
// PORT (default 8793)
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { apiBaseUrl, readServerConfig, OAUTH_SCOPES, type ServerConfig } from './config.js';
import { AUTH_HEADER } from './imanage-api.js';
import {
authorizeUrl,
tokenExchange,
refreshExchange,
parseTokenResponse,
isExpired,
type ImanageTokenSet,
} from './imanage-oauth.js';
// A server-side session: the tokens for one authorized user + when they were
// minted (iManage returns no issued-at, so we track wall-clock at mint to compute
// expiry). In-memory here keyed by an opaque session id carried in an HttpOnly
// cookie; a production deployment persists this to hanzoai/kv (Valkey) so sessions
// survive a restart.
interface Session {
token: ImanageTokenSet;
/** Wall-clock epoch seconds when the token was last minted/refreshed. */
mintedAt: number;
}
const sessions = new Map<string, Session>();
const SESSION_COOKIE = 'imanage_sid';
// A pending OAuth `state` (CSRF token) minted at /oauth/install and consumed at
// /oauth/callback. A production deployment persists these (Valkey, short TTL); a
// Set is enough for a single instance.
const pendingStates = new Set<string>();
function nowSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
// log emits ONLY non-content bookkeeping fields. Callers must never pass a request
// or response body here — see the confidentiality note at the top of the file.
function log(fields: Record<string, unknown>): void {
console.log(JSON.stringify(fields));
}
// sessionIdFromCookie reads the session id from the Cookie header. Minimal parser —
// we only need our one cookie.
function sessionIdFromCookie(req: IncomingMessage): string | undefined {
const raw = req.headers.cookie;
if (!raw) return undefined;
for (const part of raw.split(';')) {
const [k, ...v] = part.trim().split('=');
if (k === SESSION_COOKIE) return decodeURIComponent(v.join('='));
}
return undefined;
}
// GET /oauth/install → 302 to iManage's consent screen. `state` is a fresh CSRF
// token re-checked on callback.
function handleInstall(cfg: ServerConfig, res: ServerResponse): void {
const state = randomUUID();
pendingStates.add(state);
const url = authorizeUrl({
host: cfg.imanageHost,
clientId: cfg.imanageClientId,
redirectUri: cfg.imanageRedirectUri,
scopes: OAUTH_SCOPES,
state,
});
res.writeHead(302, { Location: url });
res.end();
}
// GET /oauth/callback?code=…&state=… → verify state, exchange the code for a token
// (secret in the server-side body), open a session, and set the cookie.
async function handleOAuthCallback(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
if (!code) return json(res, 400, { error: 'missing code' });
if (!state || !pendingStates.delete(state)) return json(res, 400, { error: 'invalid state' });
const exReq = tokenExchange({
host: cfg.imanageHost,
clientId: cfg.imanageClientId,
clientSecret: cfg.imanageClientSecret,
code,
redirectUri: cfg.imanageRedirectUri,
});
let token: ImanageTokenSet;
try {
const resp = await fetch(exReq.url, { method: 'POST', headers: exReq.headers, body: exReq.body });
token = parseTokenResponse(await resp.json().catch(() => ({})));
} catch (e: any) {
return json(res, 400, { error: e?.message || 'token exchange failed' });
}
const sid = randomUUID();
sessions.set(sid, { token, mintedAt: nowSeconds() });
log({ msg: 'oauth: session opened', sid });
res.writeHead(200, {
'Content-Type': 'application/json',
'Set-Cookie': `${SESSION_COOKIE}=${sid}; HttpOnly; Secure; SameSite=Lax; Path=/`,
});
res.end(JSON.stringify({ ok: true, installed: true }));
}
// GET /config → the non-secret scoping defaults so a single-tenant panel opens
// pre-scoped. Never returns the client secret or any token.
function handleConfig(cfg: ServerConfig, res: ServerResponse): void {
json(res, 200, { customerId: cfg.customerId, libraryId: cfg.libraryId });
}
// freshToken returns a session's access token, refreshing first if it has expired.
// The refresh may rotate iManage's refresh token, so we store the new set. Throws if
// the refresh fails (the caller returns 401 and the user re-installs).
async function freshToken(cfg: ServerConfig, sid: string, session: Session): Promise<string> {
if (!isExpired(session.token, session.mintedAt, nowSeconds())) return session.token.access_token;
const refreshToken = session.token.refresh_token;
if (!refreshToken) throw new Error('session expired and no refresh token');
const rReq = refreshExchange({
host: cfg.imanageHost,
clientId: cfg.imanageClientId,
clientSecret: cfg.imanageClientSecret,
refreshToken,
});
const resp = await fetch(rReq.url, { method: 'POST', headers: rReq.headers, body: rReq.body });
const next = parseTokenResponse(await resp.json().catch(() => ({})));
// iManage may or may not rotate the refresh token; keep the prior one if absent.
if (!next.refresh_token) next.refresh_token = refreshToken;
sessions.set(sid, { token: next, mintedAt: nowSeconds() });
log({ msg: 'oauth: token refreshed', sid });
return next.access_token;
}
// ALL /proxy/* → forward to the iManage Work API. The browser sends the Work API
// path (after /proxy), already customer/library-scoped; the server injects the
// access token (X-Auth-Token, never exposed to the browser) and forwards the
// method, query, and body. This is the API proxy that keeps the secret + tokens
// server-side while letting the panel read workspaces/documents/search and PATCH a
// profile. It NEVER logs the forwarded body or the upstream body.
async function handleProxy(
cfg: ServerConfig,
req: IncomingMessage,
res: ServerResponse,
url: URL,
): Promise<void> {
const sid = sessionIdFromCookie(req);
const session = sid ? sessions.get(sid) : undefined;
if (!sid || !session) return json(res, 401, { error: 'not authenticated' });
let accessToken: string;
try {
accessToken = await freshToken(cfg, sid, session);
} catch (e: any) {
return json(res, 401, { error: e?.message || 'token refresh failed' });
}
// The path after /proxy is the Work API path relative to the version root, e.g.
// /proxy/customers/1/libraries/ACTIVE_US/workspaces → ${apiBase}/customers/…. We
// build the target from apiBase so it can only ever reach the iManage host.
const restPath = url.pathname.replace(/^\/proxy/, '') || '/';
const target = `${apiBaseUrl(cfg.imanageApiHost ?? cfg.imanageHost)}${restPath}${url.search}`;
const method = req.method || 'GET';
const body = method === 'GET' || method === 'HEAD' ? undefined : await readRawBody(req);
try {
const upstream = await fetch(target, {
method,
headers: {
[AUTH_HEADER]: accessToken,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body,
});
const passthroughType = upstream.headers.get('Content-Type') || 'application/json';
const buf = Buffer.from(await upstream.arrayBuffer());
res.writeHead(upstream.status, { 'Content-Type': passthroughType });
res.end(buf);
} catch (e: any) {
// Log the target path (no query/body) and the error only — never content.
log({ msg: 'proxy: upstream error', error: e?.message, path: restPath });
return json(res, 502, { error: 'upstream request failed' });
}
}
// readRawBody collects the raw request bytes as a utf8 string.
async function readRawBody(req: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
return Buffer.concat(chunks).toString('utf8');
}
// createHandler is the request router. Everything is a small handler over the pure
// modules. Exported so a test can drive it with a mock req/res if desired.
export function createHandler(cfg: ServerConfig) {
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const url = new URL(req.url || '/', `http://localhost:${cfg.port}`);
try {
if (req.method === 'GET' && url.pathname === '/oauth/install') return handleInstall(cfg, res);
if (req.method === 'GET' && url.pathname === '/oauth/callback') return await handleOAuthCallback(cfg, url, res);
if (req.method === 'GET' && url.pathname === '/config') return handleConfig(cfg, res);
if (url.pathname === '/proxy' || url.pathname.startsWith('/proxy/')) return await handleProxy(cfg, req, res, url);
if (req.method === 'GET' && url.pathname === '/healthz') return json(res, 200, { ok: true });
return json(res, 404, { error: 'not found' });
} catch (e: any) {
log({ msg: 'unhandled error', error: e?.message });
return json(res, 500, { error: 'internal error' });
}
};
}
// main boots the server when run directly. Import-safe: only the direct entry
// listens, so tests import the handlers without opening a port.
export function main(): void {
const cfg = readServerConfig(process.env);
const server = createServer(createHandler(cfg));
server.listen(cfg.port, () => {
log({ msg: 'hanzo imanage service up', port: cfg.port, host: cfg.imanageHost });
});
}
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
main();
}
+50
View File
@@ -0,0 +1,50 @@
:root {
color-scheme: light dark;
--fg: #1a1a1a; --muted: #666; --bg: #fff; --line: #e3e3e3; --accent: #111;
--ok: #1a7f37; --warn: #9a6700; --error: #cf222e;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #eaeaea; --muted: #9a9a9a; --bg: #1e1e1e; --line: #333; --accent: #fff;
--ok: #3fb950; --warn: #d29922; --error: #f85149;
}
}
* { box-sizing: border-box; }
body {
font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--fg); background: var(--bg); margin: 0 auto; padding: 14px;
max-width: 720px;
}
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
header .title { font-weight: 600; font-size: 15px; }
header .host { color: var(--muted); font-size: 12px; margin-left: auto; }
label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
textarea, select, input {
width: 100%; font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--line); border-radius: 6px; padding: 8px;
}
textarea#prompt { min-height: 56px; resize: vertical; }
textarea#output { min-height: 220px; resize: vertical; }
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.row.scope { margin-top: 4px; }
.row.scope input#customer, .row.scope input#library { max-width: 140px; }
button {
font: inherit; border: 1px solid var(--line); background: var(--accent);
color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer;
}
button.secondary { background: transparent; color: var(--fg); }
button:disabled { opacity: .5; cursor: default; }
.spacer { flex: 1; }
.records { font-size: 11px; color: var(--muted); margin-top: 6px; min-height: 14px; }
.status { min-height: 18px; font-size: 12px; margin-top: 10px; color: var(--muted); }
.status.ok { color: var(--ok); } .status.warn { color: var(--warn); } .status.error { color: var(--error); }
.hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
footer { margin-top: 12px; font-size: 11px; color: var(--muted); }
select#model { width: auto; max-width: 150px; padding: 4px 8px; font-size: 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.chip {
padding: 5px 10px; border-radius: 999px; font-size: 13px;
background: transparent; color: var(--fg); border: 1px solid var(--line);
}
.chip:hover:not(:disabled) { border-color: var(--accent); }
details summary { cursor: pointer; font-size: 12px; color: var(--muted); margin-top: 10px; }
+88
View File
@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from 'vitest';
import type { AiClient, ChatCompletion, ChatCompletionCreateParams } from '@hanzo/ai';
import { ACTIONS, actionList, actionPrompt, isActionId, runAction } from '../src/actions.js';
import { buildContext } from '../src/documents.js';
const ctx = buildContext([{ title: 'Document', blocks: ['Document: NDA — mutual, 3-year term'] }]);
function mockClient(text: string) {
const reply: ChatCompletion = {
id: 'c', object: 'chat.completion', created: 0, model: 'zen5',
choices: [{ index: 0, message: { role: 'assistant', content: text }, finish_reason: 'stop' }],
};
const create = vi.fn(async (_p: ChatCompletionCreateParams) => reply);
return { client: { chat: { completions: { create } }, models: { list: vi.fn() } } as unknown as AiClient, create };
}
describe('actions: catalog', () => {
it('exposes exactly the four documented actions', () => {
expect(Object.keys(ACTIONS).sort()).toEqual(
['compareDocuments', 'extractClauses', 'summarizeDocument', 'synthesizeMatter'].sort(),
);
});
it('every action has a non-empty label and a substantial prompt', () => {
for (const [id, a] of Object.entries(ACTIONS)) {
expect(a.label.length, id).toBeGreaterThan(0);
expect(a.prompt.length, id).toBeGreaterThan(20);
}
});
it('actionList mirrors the catalog order and shape', () => {
const list = actionList();
expect(list.map((a) => a.id)).toEqual(Object.keys(ACTIONS));
expect(list[0]).toEqual({ id: 'summarizeDocument', label: ACTIONS.summarizeDocument.label });
});
});
describe('actions: prompt intent', () => {
it('summarizeDocument grounds in the text and handles profile-only', () => {
const p = actionPrompt('summarizeDocument');
expect(p).toMatch(/summar/i);
expect(p).toMatch(/parties|obligations|terms/i);
expect(p).toMatch(/metadata|profile/i);
});
it('extractClauses asks for parties, dates, clauses (contract review)', () => {
const p = actionPrompt('extractClauses');
expect(p).toMatch(/parties/i);
expect(p).toMatch(/dates/i);
expect(p).toMatch(/clauses/i);
expect(p).toMatch(/governing law|indemnity|termination/i);
expect(p).toMatch(/never infer|None found/i);
});
it('synthesizeMatter synthesizes across a set and attributes by document', () => {
const p = actionPrompt('synthesizeMatter');
expect(p).toMatch(/synthesize/i);
expect(p).toMatch(/attribute|by name or number/i);
});
it('compareDocuments compares differences side by side', () => {
const p = actionPrompt('compareDocuments');
expect(p).toMatch(/compare/i);
expect(p).toMatch(/difference/i);
});
});
describe('actions: isActionId / actionPrompt guards', () => {
it('narrows known ids and rejects unknown', () => {
expect(isActionId('summarizeDocument')).toBe(true);
expect(isActionId('nope')).toBe(false);
});
it('actionPrompt throws on an unknown id', () => {
expect(() => actionPrompt('nope')).toThrow(/Unknown action/);
});
});
describe('actions: runAction', () => {
it('routes the resolved prompt through ask (one code path to the model)', async () => {
const { client, create } = mockClient('clauses extracted');
const out = await runAction('extractClauses', ctx, { workspaceName: 'Acme' }, { client });
expect(out).toBe('clauses extracted');
const user = String(create.mock.calls[0][0].messages[1].content);
expect(user).toContain(ACTIONS.extractClauses.prompt);
expect(user).toContain('Document: NDA');
});
it('rejects (not synchronously throws) on an unknown id', async () => {
await expect(runAction('nope', ctx)).rejects.toThrow(/Unknown action/);
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest';
import {
HANZO_API_BASE_URL,
DEFAULT_MODEL,
APIKEY_STORAGE_KEY,
DOCUMENT_CHAR_BUDGET,
pickBearer,
chatCompletionsURL,
modelsURL,
hosts,
normalizeHost,
isHttpUrl,
apiBaseUrl,
WORK_API_VERSION,
readServerConfig,
} from '../src/config.js';
describe('config: gateway constants', () => {
it('points at api.hanzo.ai with /v1 paths and never /api/', () => {
expect(HANZO_API_BASE_URL).toBe('https://api.hanzo.ai');
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
expect(chatCompletionsURL()).not.toContain('/api/');
expect(modelsURL()).not.toContain('/api/');
});
it('defaults to a zen model and a sane budget', () => {
expect(DEFAULT_MODEL).toBe('zen5');
expect(DOCUMENT_CHAR_BUDGET).toBeGreaterThan(10_000);
expect(APIKEY_STORAGE_KEY).toContain('imanage');
});
});
describe('config: pickBearer', () => {
it('prefers a pasted key over an oauth token', () => {
expect(pickBearer('hk-abc', 'oauth-xyz')).toBe('hk-abc');
});
it('falls back to the oauth token, trimming whitespace', () => {
expect(pickBearer(' ', ' tok ')).toBe('tok');
});
it('returns empty when both are blank (anonymous)', () => {
expect(pickBearer('', '')).toBe('');
expect(pickBearer(' ', '')).toBe('');
});
});
describe('config: normalizeHost', () => {
it('adds https:// to a bare host', () => {
expect(normalizeHost('cloudimanage.com')).toBe('https://cloudimanage.com');
});
it('keeps an explicit scheme and strips path + trailing slash', () => {
expect(normalizeHost('https://work.firm.com/')).toBe('https://work.firm.com');
expect(normalizeHost('https://work.firm.com/api/v2')).toBe('https://work.firm.com');
});
it('preserves a port', () => {
expect(normalizeHost('work.firm.com:8443')).toBe('https://work.firm.com:8443');
});
it('returns empty for an empty host', () => {
expect(normalizeHost('')).toBe('');
expect(normalizeHost(' ')).toBe('');
});
});
describe('config: isHttpUrl', () => {
it('accepts bare hosts and full urls', () => {
expect(isHttpUrl('cloudimanage.com')).toBe(true);
expect(isHttpUrl('https://work.firm.com')).toBe(true);
expect(isHttpUrl('http://localhost:8793')).toBe(true);
});
it('rejects empty / nonsense', () => {
expect(isHttpUrl(undefined)).toBe(false);
expect(isHttpUrl('')).toBe(false);
expect(isHttpUrl('ftp://x')).toBe(false);
});
});
describe('config: hosts + apiBaseUrl', () => {
it('resolves auth + api to the same host by default', () => {
const h = hosts('cloudimanage.com');
expect(h.auth).toBe('https://cloudimanage.com');
expect(h.api).toBe('https://cloudimanage.com');
});
it('honours a distinct api host', () => {
const h = hosts('cc.firm.com', 'work.firm.com');
expect(h.auth).toBe('https://cc.firm.com');
expect(h.api).toBe('https://work.firm.com');
});
it('builds the /api/v2 Work API root (iManage own path)', () => {
expect(apiBaseUrl('cloudimanage.com')).toBe('https://cloudimanage.com/api/v2');
expect(apiBaseUrl('https://work.firm.com/')).toBe('https://work.firm.com/api/v2');
expect(WORK_API_VERSION).toBe('v2');
});
});
describe('config: readServerConfig', () => {
const base = {
IMANAGE_CLIENT_ID: 'cid',
IMANAGE_CLIENT_SECRET: 'secret',
IMANAGE_REDIRECT_URI: 'https://imanage.hanzo.ai/oauth/callback',
IMANAGE_HOST: 'https://cloudimanage.com',
};
it('reads a full config, defaulting port to 8793 and empty scope defaults', () => {
const cfg = readServerConfig(base);
expect(cfg.imanageClientId).toBe('cid');
expect(cfg.imanageClientSecret).toBe('secret');
expect(cfg.imanageRedirectUri).toBe('https://imanage.hanzo.ai/oauth/callback');
expect(cfg.imanageHost).toBe('https://cloudimanage.com');
expect(cfg.imanageApiHost).toBeUndefined();
expect(cfg.customerId).toBe('');
expect(cfg.libraryId).toBe('');
expect(cfg.port).toBe(8793);
});
it('normalizes a bare host and honours PORT + scope defaults + api host', () => {
const cfg = readServerConfig({
...base,
IMANAGE_HOST: 'cloudimanage.com',
IMANAGE_API_HOST: 'work.firm.com',
IMANAGE_CUSTOMER_ID: '1',
IMANAGE_LIBRARY_ID: 'ACTIVE_US',
PORT: '9100',
});
expect(cfg.imanageHost).toBe('https://cloudimanage.com');
expect(cfg.imanageApiHost).toBe('https://work.firm.com');
expect(cfg.customerId).toBe('1');
expect(cfg.libraryId).toBe('ACTIVE_US');
expect(cfg.port).toBe(9100);
});
it('throws listing every missing required secret/host', () => {
expect(() => readServerConfig({})).toThrow(/IMANAGE_CLIENT_ID/);
expect(() => readServerConfig({})).toThrow(/IMANAGE_CLIENT_SECRET/);
expect(() => readServerConfig({})).toThrow(/IMANAGE_REDIRECT_URI/);
expect(() => readServerConfig({})).toThrow(/IMANAGE_HOST/);
expect(() => readServerConfig({ IMANAGE_CLIENT_ID: 'x' })).toThrow(
/IMANAGE_CLIENT_SECRET, IMANAGE_REDIRECT_URI, IMANAGE_HOST/,
);
});
it('rejects a malformed host / api host', () => {
expect(() => readServerConfig({ ...base, IMANAGE_HOST: 'not a url' })).toThrow(/IMANAGE_HOST is not a valid/);
expect(() => readServerConfig({ ...base, IMANAGE_API_HOST: 'not a host' })).toThrow(/IMANAGE_API_HOST is not a valid/);
});
});
+193
View File
@@ -0,0 +1,193 @@
import { describe, it, expect } from 'vitest';
import {
looksBinary,
extractText,
renderDocumentMeta,
renderWorkspace,
renderDocumentLine,
renderFolderLine,
contentBlocks,
buildContext,
contextNote,
documentSection,
documentIndexSection,
folderSection,
workspaceSection,
searchResultSection,
} from '../src/documents.js';
import type { DocumentEntry, Workspace, FolderEntry } from '../src/imanage-api.js';
const doc: DocumentEntry = {
id: 'ACTIVE_US!4567.1',
name: 'Master Services Agreement',
extension: 'docx',
size: 48213,
author: 'Jane Smith',
class: 'Agreement',
type: 'Document',
version: 1,
editDate: '2026-06-20',
comment: 'Final executed copy',
};
// Binary content is built from char codes so no literal control bytes live in the
// source. NUL is a reliable binary tell; CONTROLS is a run of C0 control chars.
const NUL = String.fromCharCode(0);
const CONTROLS = Array.from({ length: 12 }, (_, i) => String.fromCharCode(i + 1)).join('');
describe('documents: looksBinary', () => {
it('is false for plain text (incl. tabs and newlines)', () => {
expect(looksBinary('This is a\tcontract.\nGoverning law: NY.')).toBe(false);
expect(looksBinary('')).toBe(false);
});
it('is true for content with a NUL byte', () => {
expect(looksBinary('PK' + NUL + 'native bytes')).toBe(true);
});
it('is true for a high share of control characters', () => {
expect(looksBinary('abc' + CONTROLS)).toBe(true);
});
});
describe('documents: extractText', () => {
it('returns cleaned text for plain content', () => {
const r = extractText(' Agreement\r\ntext. ', 'MSA.txt');
expect(r.extracted).toBe(true);
expect(r.text).toBe('Agreement\ntext.');
});
it('reports binary content as not extractable (OCR out of scope)', () => {
const r = extractText('PK' + NUL + 'native docx bytes', 'MSA.docx');
expect(r.extracted).toBe(false);
expect(r.text).toBe('');
expect(r.reason).toMatch(/out of scope|binary/i);
expect(r.reason).toContain('MSA.docx');
});
it('reports empty content honestly', () => {
const r = extractText(' ');
expect(r.extracted).toBe(false);
expect(r.reason).toMatch(/no extractable text/i);
});
});
describe('documents: record rendering', () => {
it('renders a document profile with meta and comment', () => {
const t = renderDocumentMeta(doc);
expect(t).toContain('Document: Master Services Agreement');
expect(t).toContain('Type: .docx');
expect(t).toContain('Class: Agreement');
expect(t).toContain('Author: Jane Smith');
expect(t).toContain('Version: 1');
expect(t).toContain('Comment: Final executed copy');
});
it('renders a workspace, a document line, and a folder line compactly', () => {
const ws: Workspace = { id: 'W1', name: 'Acme / Falcon', description: 'M&A', client: 'Acme', matter: '2026-0042', database: 'ACTIVE_US' };
expect(renderWorkspace(ws)).toContain('Workspace: Acme / Falcon');
expect(renderWorkspace(ws)).toContain('Client: Acme · Matter: 2026-0042');
expect(renderWorkspace(ws)).toContain('M&A');
expect(renderDocumentLine(doc)).toBe('Doc: Master Services Agreement.docx — Jane Smith (2026-06-20)');
const f: FolderEntry = { id: 'F1', name: 'Contracts' };
expect(renderFolderLine(f)).toBe('Folder: Contracts');
});
});
describe('documents: contentBlocks', () => {
it('splits on blank lines and trims, dropping empties', () => {
expect(contentBlocks('Para 1.\n\n\nPara 2.\n\n ')).toEqual(['Para 1.', 'Para 2.']);
});
});
describe('documents: buildContext (the windowing algorithm)', () => {
it('returns empty for no blocks', () => {
const ctx = buildContext([{ title: 'Document', blocks: [] }]);
expect(ctx).toEqual({ text: '', totalBlocks: 0, includedBlocks: 0, truncated: false });
});
it('includes all blocks under section headers when within budget', () => {
const ctx = buildContext([
{ title: 'Profile', blocks: ['a', 'b'] },
{ title: 'Text', blocks: ['c'] },
]);
expect(ctx.totalBlocks).toBe(3);
expect(ctx.includedBlocks).toBe(3);
expect(ctx.truncated).toBe(false);
expect(ctx.text).toContain('## Profile');
expect(ctx.text).toContain('## Text');
expect(ctx.text.indexOf('## Profile')).toBeLessThan(ctx.text.indexOf('## Text'));
});
it('windows in order and reports truncation when the budget is exceeded', () => {
const blocks = ['x'.repeat(30), 'y'.repeat(30), 'z'.repeat(30)];
const ctx = buildContext([{ title: 'Text', blocks }], 60);
expect(ctx.totalBlocks).toBe(3);
expect(ctx.includedBlocks).toBeLessThan(3);
expect(ctx.truncated).toBe(true);
expect(ctx.text).toContain('xxx');
expect(ctx.text).not.toContain('zzz');
expect(ctx.text.length).toBeLessThanOrEqual(60);
});
it('always includes at least the first block, hard-cutting it if it alone overflows', () => {
const huge = 'q'.repeat(500);
const ctx = buildContext([{ title: 'Text', blocks: [huge, 'next'] }], 50);
expect(ctx.includedBlocks).toBe(1);
expect(ctx.truncated).toBe(true);
expect(ctx.text.length).toBe(50);
expect(ctx.text.startsWith('## Text')).toBe(true);
});
it('skips empty sections without charging a header', () => {
const ctx = buildContext([
{ title: 'Empty', blocks: [] },
{ title: 'Text', blocks: ['a'] },
]);
expect(ctx.text).not.toContain('## Empty');
expect(ctx.text).toContain('## Text');
expect(ctx.includedBlocks).toBe(1);
});
});
describe('documents: contextNote', () => {
it('is honest about no records', () => {
expect(contextNote({ text: '', totalBlocks: 0, includedBlocks: 0, truncated: false })).toMatch(/No document/);
});
it('reports full coverage', () => {
expect(contextNote({ text: 'x', totalBlocks: 3, includedBlocks: 3, truncated: false })).toMatch(/all 3 sections/);
});
it('reports truncation with the counts', () => {
expect(contextNote({ text: 'x', totalBlocks: 10, includedBlocks: 4, truncated: true })).toMatch(/4 of 10 sections/);
});
});
describe('documents: section builders', () => {
it('documentSection assembles profile + paragraph-blocked content', () => {
const section = documentSection(doc, { text: 'Clause 1.\n\nClause 2.', extracted: true });
expect(section.title).toBe('Document');
expect(section.blocks[0]).toContain('Document: Master Services Agreement');
expect(section.blocks).toContain('Clause 1.');
expect(section.blocks).toContain('Clause 2.');
});
it('documentSection uses the non-extractable reason as the sole content block', () => {
const section = documentSection(doc, { text: '', extracted: false, reason: 'binary - OCR out of scope' });
expect(section.blocks).toHaveLength(2);
expect(section.blocks[1]).toBe('[binary - OCR out of scope]');
});
it('index / folder / workspace / search sections turn records into a windowed context', () => {
const ws: Workspace = { id: 'W1', name: 'Acme', description: '', client: 'Acme', matter: 'M1', database: 'ACTIVE_US' };
const folders: FolderEntry[] = [{ id: 'F1', name: 'Contracts' }];
const ctx = buildContext([
workspaceSection([ws]),
folderSection(folders),
documentIndexSection([doc]),
searchResultSection([doc]),
]);
expect(ctx.totalBlocks).toBe(4);
expect(ctx.truncated).toBe(false);
expect(ctx.text).toContain('## Workspaces');
expect(ctx.text).toContain('## Folders');
expect(ctx.text).toContain('## Documents');
expect(ctx.text).toContain('## Search results');
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, vi } from 'vitest';
import type { AiClient, ChatCompletion, ChatCompletionCreateParams, Model } from '@hanzo/ai';
import {
SYSTEM_PROMPT,
buildMessages,
extractContent,
ask,
listModels,
type MatterMeta,
} from '../src/hanzo.js';
import { buildContext, type DocumentContext } from '../src/documents.js';
const ctx: DocumentContext = buildContext([{ title: 'Document', blocks: ['Document: NDA — mutual'] }]);
const meta: MatterMeta = { workspaceName: 'Acme / Falcon', matterNumber: '2026-0042', client: 'Acme Corp' };
// completion builds a minimal @hanzo/ai ChatCompletion with the given content.
function completion(content: ChatCompletion['choices'][number]['message']['content']): ChatCompletion {
return {
id: 'c1',
object: 'chat.completion',
created: 0,
model: 'zen5',
choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
};
}
// mockClient captures the params the code sends and returns a canned completion.
function mockClient(reply: ChatCompletion, models: Model[] = []) {
const create = vi.fn(async (_p: ChatCompletionCreateParams, _o?: { signal?: AbortSignal }) => reply);
const list = vi.fn(async (_o?: { signal?: AbortSignal }) => models);
const client = { chat: { completions: { create } }, models: { list } } as unknown as AiClient;
return { client, create, list };
}
describe('hanzo: buildMessages', () => {
it('produces a grounded system prompt + a fenced user turn with matter meta + context note', () => {
const msgs = buildMessages('Does this NDA survive termination?', ctx, meta);
expect(msgs).toHaveLength(2);
expect(msgs[0].role).toBe('system');
expect(msgs[0].content).toBe(SYSTEM_PROMPT);
const user = String(msgs[1].content);
expect(user).toContain('Workspace: Acme / Falcon');
expect(user).toContain('Matter #: 2026-0042');
expect(user).toContain('Client: Acme Corp');
expect(user).toContain('---- document records ----');
expect(user).toContain('Document: NDA — mutual');
expect(user).toContain('---- task ----');
expect(user).toContain('Does this NDA survive termination?');
});
it('omits the header block entirely when meta is undefined', () => {
const user = String(buildMessages('q', ctx).at(1)!.content);
expect(user).not.toContain('Workspace:');
expect(user).not.toContain('Matter #:');
expect(user).toContain('---- task ----');
});
it('SYSTEM_PROMPT forbids inventing terms and disclaims legal advice + affirms confidentiality', () => {
expect(SYSTEM_PROMPT).toMatch(/never invent/i);
expect(SYSTEM_PROMPT).toMatch(/not legal advice/i);
expect(SYSTEM_PROMPT).toMatch(/confidential/i);
});
});
describe('hanzo: extractContent', () => {
it('reads a plain string content', () => {
expect(extractContent(completion('hello'))).toBe('hello');
});
it('concatenates text parts of an array content', () => {
const parts = [
{ type: 'text', text: 'a' },
{ type: 'image_url', image_url: { url: 'x' } },
{ type: 'text', text: 'b' },
] as any;
expect(extractContent(completion(parts))).toBe('ab');
});
it('throws on empty content', () => {
expect(() => extractContent(completion(''))).toThrow(/no content/);
expect(() => extractContent(completion(undefined))).toThrow(/no content/);
});
});
describe('hanzo: ask', () => {
it('sends model + messages non-streaming through the injected client and returns the text', async () => {
const { client, create } = mockClient(completion('the NDA survives for 3 years'));
const out = await ask('Does this NDA survive termination?', ctx, meta, { client, model: 'zen5', temperature: 0.2 });
expect(out).toBe('the NDA survives for 3 years');
expect(create).toHaveBeenCalledOnce();
const [params, options] = create.mock.calls[0];
expect(params.model).toBe('zen5');
expect(params.stream).toBe(false);
expect(params.temperature).toBe(0.2);
expect(params.messages[0].role).toBe('system');
expect(options).toBeDefined();
});
it('omits temperature from the wire when not provided', async () => {
const { client, create } = mockClient(completion('ok'));
await ask('q', ctx, undefined, { client });
expect('temperature' in create.mock.calls[0][0]).toBe(false);
});
it('defaults the model to zen5', async () => {
const { client, create } = mockClient(completion('ok'));
await ask('q', ctx, undefined, { client });
expect(create.mock.calls[0][0].model).toBe('zen5');
});
it('propagates an abort signal to the client', async () => {
const { client, create } = mockClient(completion('ok'));
const controller = new AbortController();
await ask('q', ctx, undefined, { client, signal: controller.signal });
expect(create.mock.calls[0][1]?.signal).toBe(controller.signal);
});
});
describe('hanzo: listModels', () => {
it('maps the model catalog to ids', async () => {
const models: Model[] = [
{ id: 'zen5', object: 'model' },
{ id: 'zen5-pro', object: 'model' },
];
const { client } = mockClient(completion('x'), models);
expect(await listModels({ client })).toEqual(['zen5', 'zen5-pro']);
});
});
+161
View File
@@ -0,0 +1,161 @@
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; iManages ! 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 containers 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 });
});
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest';
import {
authorizeUrl,
tokenUrl,
tokenExchange,
refreshExchange,
parseTokenResponse,
tokenExpiresAt,
isExpired,
} from '../src/imanage-oauth.js';
describe('oauth: authorizeUrl', () => {
it('builds the control-center consent URL with the standard params', () => {
const url = new URL(
authorizeUrl({
host: 'cloudimanage.com',
clientId: 'cid',
redirectUri: 'https://imanage.hanzo.ai/oauth/callback',
state: 'st-1',
}),
);
expect(url.origin).toBe('https://cloudimanage.com');
expect(url.pathname).toBe('/auth/oauth2/authorize');
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('client_id')).toBe('cid');
expect(url.searchParams.get('redirect_uri')).toBe('https://imanage.hanzo.ai/oauth/callback');
expect(url.searchParams.get('state')).toBe('st-1');
});
it('normalizes a bare host and omits scope when empty', () => {
const url = new URL(
authorizeUrl({ host: 'work.firm.com', clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: [] }),
);
expect(url.origin).toBe('https://work.firm.com');
expect(url.searchParams.has('scope')).toBe(false);
});
it('space-joins scopes when present', () => {
const url = new URL(
authorizeUrl({ host: 'cloudimanage.com', clientId: 'c', redirectUri: 'https://x/cb', state: 's', scopes: ['user', 'admin'] }),
);
expect(url.searchParams.get('scope')).toBe('user admin');
});
});
describe('oauth: token endpoints', () => {
it('resolves the token URL on the control-center host', () => {
expect(tokenUrl('cloudimanage.com')).toBe('https://cloudimanage.com/auth/oauth2/token');
expect(tokenUrl('https://work.firm.com/')).toBe('https://work.firm.com/auth/oauth2/token');
});
});
describe('oauth: tokenExchange', () => {
it('form-encodes the code grant with client credentials + redirect_uri in the body', () => {
const req = tokenExchange({
host: 'cloudimanage.com',
clientId: 'cid',
clientSecret: 'sec',
code: 'the-code',
redirectUri: 'https://imanage.hanzo.ai/oauth/callback',
});
expect(req.url).toBe('https://cloudimanage.com/auth/oauth2/token');
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
const body = new URLSearchParams(req.body);
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('code')).toBe('the-code');
expect(body.get('client_id')).toBe('cid');
expect(body.get('client_secret')).toBe('sec');
expect(body.get('redirect_uri')).toBe('https://imanage.hanzo.ai/oauth/callback');
});
it('never puts the secret in the URL', () => {
const req = tokenExchange({ host: 'cloudimanage.com', clientId: 'c', clientSecret: 'SUPER', code: 'x', redirectUri: 'https://x/cb' });
expect(req.url).not.toContain('SUPER');
});
});
describe('oauth: refreshExchange', () => {
it('form-encodes the refresh grant with client credentials', () => {
const req = refreshExchange({ host: 'cloudimanage.com', clientId: 'cid', clientSecret: 'sec', refreshToken: 'r-1' });
expect(req.url).toBe('https://cloudimanage.com/auth/oauth2/token');
const body = new URLSearchParams(req.body);
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('r-1');
expect(body.get('client_id')).toBe('cid');
expect(body.get('client_secret')).toBe('sec');
expect(body.has('code')).toBe(false);
});
});
describe('oauth: parseTokenResponse', () => {
it('parses a full token set', () => {
const t = parseTokenResponse({
access_token: 'at',
refresh_token: 'rt',
expires_in: 3600,
token_type: 'Bearer',
scope: 'user',
});
expect(t.access_token).toBe('at');
expect(t.refresh_token).toBe('rt');
expect(t.expires_in).toBe(3600);
expect(t.token_type).toBe('Bearer');
expect(t.scope).toBe('user');
});
it('throws on an iManage error payload with the description', () => {
expect(() => parseTokenResponse({ error: 'invalid_grant', error_description: 'code used' })).toThrow(/code used/);
});
it('throws when access_token is missing or empty', () => {
expect(() => parseTokenResponse({})).toThrow(/missing access_token/);
expect(() => parseTokenResponse({ access_token: '' })).toThrow(/missing access_token/);
expect(() => parseTokenResponse(null)).toThrow(/empty token response/);
});
it('drops non-string/non-number optional fields defensively', () => {
const t = parseTokenResponse({ access_token: 'at', refresh_token: 123, expires_in: 'soon' });
expect(t.refresh_token).toBeUndefined();
expect(t.expires_in).toBeUndefined();
});
});
describe('oauth: expiry math (minted-at based — iManage sends no issued-at)', () => {
it('computes expiry from mintedAt + expires_in minus skew', () => {
const t = { access_token: 'a', expires_in: 3600 };
expect(tokenExpiresAt(t, 1000, 60)).toBe(1000 + 3600 - 60);
});
it('treats a token with no expires_in as already at expiry (minus skew)', () => {
const t = { access_token: 'a' };
expect(tokenExpiresAt(t, 5000, 0)).toBe(5000);
});
it('reports not-expired before, expired at/after the skewed boundary', () => {
const t = { access_token: 'a', expires_in: 3600 };
const minted = 1000;
const boundary = minted + 3600 - 60;
expect(isExpired(t, minted, boundary - 1, 60)).toBe(false);
expect(isExpired(t, minted, boundary, 60)).toBe(true);
expect(isExpired(t, minted, boundary + 100, 60)).toBe(true);
});
});
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect, vi } from 'vitest';
import { parseLaunchContext, proxyUrl, createProxyClient } from '../src/panel.js';
describe('panel: parseLaunchContext', () => {
it('reads snake_case customer/library/workspace/document ids', () => {
expect(parseLaunchContext('?customer_id=1&library_id=ACTIVE_US&workspace_id=W1&document_id=D1')).toEqual({
customerId: '1',
libraryId: 'ACTIVE_US',
workspaceId: 'W1',
documentId: 'D1',
});
});
it('accepts camelCase fallbacks and defaults to empty', () => {
expect(parseLaunchContext('?customerId=7&libraryId=L')).toEqual({
customerId: '7',
libraryId: 'L',
workspaceId: '',
documentId: '',
});
expect(parseLaunchContext('')).toEqual({ customerId: '', libraryId: '', workspaceId: '', documentId: '' });
});
});
describe('panel: proxyUrl', () => {
it('prefixes /proxy and appends a query, dropping empties', () => {
expect(proxyUrl('', '/customers/1/libraries/ACTIVE_US/workspaces', { limit: '100', offset: '' })).toBe(
'/proxy/customers/1/libraries/ACTIVE_US/workspaces?limit=100',
);
});
it('strips a trailing slash from the base', () => {
expect(proxyUrl('https://imanage.hanzo.ai/', '/x')).toBe('https://imanage.hanzo.ai/proxy/x');
});
});
// jsonResponse builds a fetch Response-like from an iManage envelope body.
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
function textResponse(body: string, status = 200): Response {
return new Response(body, { status, headers: { 'Content-Type': 'text/plain' } });
}
// fetchMockOf wraps a responder in a fetch-typed mock so calls[i] carries the
// (url, init) arg tuple TypeScript expects.
function fetchMockOf(responder: () => Response) {
return vi.fn((_url: RequestInfo | URL, _init?: RequestInit) => Promise.resolve(responder()));
}
const scope = { customerId: '1', libraryId: 'ACTIVE_US' };
describe('panel: createProxyClient — scoping + shaping', () => {
it('lists workspaces with the customer/library path, credentials, parsed items + paging', async () => {
const fetchMock = fetchMockOf(() =>
jsonResponse({ data: [{ id: 'ACTIVE_US!12', name: 'Acme / Falcon', client: 'Acme', matter: 'M1' }], total_count: 1 }),
);
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
const { items, paging } = await client.listWorkspaces({ limit: 100 });
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Acme / Falcon');
expect(paging.total).toBe(1);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('/proxy/customers/1/libraries/ACTIVE_US/workspaces?limit=100');
expect((init as RequestInit).credentials).toBe('include');
});
it('gets one workspace by id', async () => {
const fetchMock = fetchMockOf(() => jsonResponse({ data: { id: 'W1', name: 'Solo' } }));
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
const ws = await client.getWorkspace('W1');
expect(ws.name).toBe('Solo');
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/customers/1/libraries/ACTIVE_US/workspaces/W1');
});
it('lists folder children split into folders + documents', async () => {
const fetchMock = fetchMockOf(() =>
jsonResponse({
data: [
{ id: 'F1', name: 'Contracts', type: 'folder' },
{ id: 'ACTIVE_US!4567.1', name: 'MSA', type: 'document', extension: 'DOCX' },
],
}),
);
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
const { items } = await client.listFolderChildren('W1', { limit: 100 });
expect(items.folders.map((f) => f.id)).toEqual(['F1']);
expect(items.documents.map((d) => d.id)).toEqual(['ACTIVE_US!4567.1']);
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/customers/1/libraries/ACTIVE_US/folders/W1/children?limit=100');
});
it('gets a document profile and parses it', async () => {
const fetchMock = fetchMockOf(() =>
jsonResponse({ data: { id: 'ACTIVE_US!4567.1', name: 'MSA', extension: 'DOCX', author_description: 'Jane Smith' } }),
);
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
const doc = await client.getDocument('ACTIVE_US!4567.1');
expect(doc.name).toBe('MSA');
expect(doc.author).toBe('Jane Smith');
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/customers/1/libraries/ACTIVE_US/documents/ACTIVE_US!4567.1');
});
it('fetches document content via /download and extracts text', async () => {
const fetchMock = fetchMockOf(() => textResponse('Governing law: New York.\n\nTerm: 3 years.'));
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
const extracted = await client.getDocumentContent('ACTIVE_US!4567.1');
expect(extracted.extracted).toBe(true);
expect(extracted.text).toContain('Governing law: New York.');
expect(String(fetchMock.mock.calls[0][0])).toBe(
'/proxy/customers/1/libraries/ACTIVE_US/documents/ACTIVE_US!4567.1/download',
);
});
it('searches documents with q + pagination', async () => {
const fetchMock = fetchMockOf(() =>
jsonResponse({ data: [{ id: 'ACTIVE_US!4567.1', name: 'MSA', extension: 'DOCX' }], overflow: true }),
);
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
const { items, paging } = await client.search('services agreement', { limit: 50 });
expect(items).toHaveLength(1);
expect(paging.hasMore).toBe(true);
expect(String(fetchMock.mock.calls[0][0])).toBe(
'/proxy/customers/1/libraries/ACTIVE_US/documents/search?q=services+agreement&limit=50',
);
});
it('updateDocumentProfile PATCHes a data-wrapped body (the one write path)', async () => {
const fetchMock = fetchMockOf(() => jsonResponse({ data: { id: 'D1' } }));
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
await client.updateDocumentProfile('D1', { comment: 'AI summary' });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('/proxy/customers/1/libraries/ACTIVE_US/documents/D1');
expect((init as RequestInit).method).toBe('PATCH');
expect((init as RequestInit).credentials).toBe('include');
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ data: { comment: 'AI summary' } });
});
it('surfaces a proxy error with status + message', async () => {
const fetchMock = fetchMockOf(() => jsonResponse({ error: 'not authenticated' }, 401));
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
await expect(client.listWorkspaces()).rejects.toThrow(/401.*not authenticated/);
});
});
+161
View File
@@ -0,0 +1,161 @@
import { describe, it, expect } from 'vitest';
import {
parseLibraries,
parseWorkspaces,
parseWorkspaceDetail,
parseFolderChildren,
parseDocument,
parseSearchResults,
} from '../src/imanage-api.js';
// A realistic iManage Work API v2 document profile (envelope-wrapped, trimmed to
// the fields we surface). iManage separates coded fields from *_description labels.
const documentEnvelope = {
data: {
id: 'ACTIVE_US!4567.1',
name: 'Master Services Agreement - Acme Corp',
extension: 'DOCX',
size: 48213,
author: 'jsmith',
author_description: 'Jane Smith',
class: 'AGR',
class_description: 'Agreement',
type: 'DOC',
type_description: 'Document',
version: 1,
edit_date: '2026-06-20T14:00:00Z',
comment: 'Final executed copy',
},
};
describe('parse: libraries', () => {
it('reads id + name from an envelope and drops idless rows', () => {
const libs = parseLibraries({ data: [{ id: 'ACTIVE_US', name: 'Active (US)' }, { name: 'no id' }] });
expect(libs).toEqual([{ id: 'ACTIVE_US', name: 'Active (US)' }]);
});
it('falls id back to name (and vice versa)', () => {
expect(parseLibraries([{ id: 'X' }])[0]).toEqual({ id: 'X', name: 'X' });
});
});
describe('parse: workspaces', () => {
it('reads the matter fields from an envelope array and drops idless rows', () => {
const wss = parseWorkspaces({
data: [
{
id: 'ACTIVE_US!12',
name: 'Acme Corp / Project Falcon',
wstype: 'workspace',
description: 'M&A due diligence',
client: 'Acme Corp',
matter: '2026-0042',
database: 'ACTIVE_US',
},
{ name: 'no id' },
],
});
expect(wss).toHaveLength(1);
expect(wss[0]).toEqual({
id: 'ACTIVE_US!12',
name: 'Acme Corp / Project Falcon',
description: 'M&A due diligence',
client: 'Acme Corp',
matter: '2026-0042',
database: 'ACTIVE_US',
});
});
it('reads client/matter from custom1/custom2 fallbacks', () => {
const w = parseWorkspaces([{ id: 'W1', name: 'W', custom1: 'ClientCo', custom2: 'M-9' }])[0];
expect(w.client).toBe('ClientCo');
expect(w.matter).toBe('M-9');
});
it('parseWorkspaceDetail unwraps a single workspace envelope', () => {
const w = parseWorkspaceDetail({ data: { id: 'W2', name: 'Solo' } });
expect(w.id).toBe('W2');
expect(w.name).toBe('Solo');
});
});
describe('parse: document profile', () => {
it('unwraps the envelope and prefers *_description labels; lowercases the extension', () => {
const doc = parseDocument(documentEnvelope);
expect(doc.id).toBe('ACTIVE_US!4567.1');
expect(doc.name).toBe('Master Services Agreement - Acme Corp');
expect(doc.extension).toBe('docx');
expect(doc.size).toBe(48213);
expect(doc.author).toBe('Jane Smith');
expect(doc.class).toBe('Agreement');
expect(doc.type).toBe('Document');
expect(doc.version).toBe(1);
expect(doc.editDate).toBe('2026-06-20T14:00:00Z');
expect(doc.comment).toBe('Final executed copy');
});
it('tolerates a bare (unwrapped) profile and missing fields', () => {
const doc = parseDocument({ id: 'D1', name: 'X' });
expect(doc.id).toBe('D1');
expect(doc.extension).toBe('');
expect(doc.size).toBe(0);
expect(doc.author).toBe('');
});
});
describe('parse: folder children (folders + documents split)', () => {
const children = {
data: [
{ id: 'FOLDER!1', name: 'Contracts', type: 'folder', wstype: 'folder' },
{
id: 'ACTIVE_US!4567.1',
name: 'MSA - Acme',
type: 'document',
extension: 'DOCX',
author_description: 'Jane Smith',
edit_date: '2026-06-20',
},
{ id: 'ACTIVE_US!4568.2', name: 'NDA', extension: 'PDF' },
],
};
it('separates typed folders from documents and untyped-with-extension rows', () => {
const { folders, documents } = parseFolderChildren(children);
expect(folders).toEqual([{ id: 'FOLDER!1', name: 'Contracts' }]);
expect(documents.map((d) => d.id)).toEqual(['ACTIVE_US!4567.1', 'ACTIVE_US!4568.2']);
expect(documents[0].author).toBe('Jane Smith');
expect(documents[1].extension).toBe('pdf');
});
it('treats an untyped row with no extension as a folder (never guessed a document)', () => {
const { folders, documents } = parseFolderChildren([{ id: 'C1', name: 'Correspondence' }]);
expect(folders).toEqual([{ id: 'C1', name: 'Correspondence' }]);
expect(documents).toEqual([]);
});
it('reads a nested { document } child object', () => {
const { documents } = parseFolderChildren([
{ type: 'document', document: { id: 'D9', name: 'Deed', extension: 'PDF' } },
]);
expect(documents[0]).toMatchObject({ id: 'D9', name: 'Deed', extension: 'pdf' });
});
it('drops idless rows from both buckets', () => {
const { folders, documents } = parseFolderChildren([{ type: 'folder', name: 'nameless' }, { extension: 'DOCX', name: 'no id doc' }]);
expect(folders).toEqual([]);
expect(documents).toEqual([]);
});
});
describe('parse: search results', () => {
it('reads document rows from an envelope and drops idless rows', () => {
const results = parseSearchResults({
data: [
{ id: 'ACTIVE_US!4567.1', name: 'MSA - Acme', extension: 'DOCX', author_description: 'Jane Smith', class_description: 'Agreement' },
{ name: 'no id' },
],
});
expect(results).toHaveLength(1);
expect(results[0].name).toBe('MSA - Acme');
expect(results[0].class).toBe('Agreement');
});
it('handles a bare array shape', () => {
expect(parseSearchResults([{ id: 'D1', name: 'X', extension: 'PDF' }])).toHaveLength(1);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
},
});
+1
View File
@@ -15,6 +15,7 @@ packages:
- 'packages/gitlab'
- 'packages/gworkspace'
- 'packages/hubspot'
- 'packages/imanage'
- 'packages/jetbrains'
- 'packages/jupyter'
- 'packages/mcp'