Merge remote-tracking branch 'origin/feat/imanage'
This commit is contained in:
@@ -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.*
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 : ''}`;
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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'}`;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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; iManage’s ! and . pass through as valid path chars', () => {
|
||||
expect(enc('ACTIVE_US!4567.1')).toBe('ACTIVE_US!4567.1');
|
||||
expect(enc('a/b')).toBe('a%2Fb');
|
||||
expect(enc('a b')).toBe('a%20b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: auth scoping', () => {
|
||||
it('every read carries the X-Auth-Token header (never Authorization: Bearer)', () => {
|
||||
for (const req of [
|
||||
listLibraries(scope),
|
||||
listWorkspaces(scope),
|
||||
getWorkspace(scope, 'W1'),
|
||||
listFolderChildren(scope, 'F1'),
|
||||
getDocument(scope, 'D1'),
|
||||
getDocumentContent(scope, 'D1'),
|
||||
search(scope, 'nda'),
|
||||
]) {
|
||||
expect(req.headers[AUTH_HEADER]).toBe('at-123');
|
||||
expect(req.headers.Authorization).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it('AUTH_HEADER is exactly X-Auth-Token', () => {
|
||||
expect(AUTH_HEADER).toBe('X-Auth-Token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: libraries + workspaces', () => {
|
||||
it('lists libraries under the customer (no library in path)', () => {
|
||||
const req = listLibraries(scope);
|
||||
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries');
|
||||
});
|
||||
it('lists workspaces scoped to customer + library with pagination', () => {
|
||||
const req = listWorkspaces(scope, { offset: 100, limit: 50 });
|
||||
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/workspaces');
|
||||
expect(q(req.url).get('offset')).toBe('100');
|
||||
expect(q(req.url).get('limit')).toBe('50');
|
||||
});
|
||||
it('gets one workspace by id', () => {
|
||||
expect(new URL(getWorkspace(scope, 'W-7').url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/workspaces/W-7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: folder children (browse)', () => {
|
||||
it('lists a container’s children (folders + documents) with pagination', () => {
|
||||
const req = listFolderChildren(scope, 'FOLDER!9', { limit: 100 });
|
||||
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/folders/FOLDER!9/children');
|
||||
expect(q(req.url).get('limit')).toBe('100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: documents', () => {
|
||||
it('gets a document profile by id', () => {
|
||||
const req = getDocument(scope, 'ACTIVE_US!4567.1');
|
||||
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/documents/ACTIVE_US!4567.1');
|
||||
});
|
||||
it('gets document content via /download', () => {
|
||||
const req = getDocumentContent(scope, 'ACTIVE_US!4567.1');
|
||||
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/documents/ACTIVE_US!4567.1/download');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: updateDocumentProfile (the one write path)', () => {
|
||||
it('PATCHes a data-wrapped profile patch', () => {
|
||||
const req = updateDocumentProfile(scope, 'D1', { comment: 'AI summary' });
|
||||
expect(req.method).toBe('PATCH');
|
||||
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/documents/D1');
|
||||
expect(req.headers['Content-Type']).toBe('application/json');
|
||||
expect(req.headers[AUTH_HEADER]).toBe('at-123');
|
||||
expect(JSON.parse(req.body)).toEqual({ data: { comment: 'AI summary' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: search', () => {
|
||||
it('searches documents across the library with q + pagination', () => {
|
||||
const req = search(scope, 'master services agreement', { limit: 25, offset: 25 });
|
||||
expect(new URL(req.url).pathname).toBe('/api/v2/customers/1/libraries/ACTIVE_US/documents/search');
|
||||
expect(q(req.url).get('q')).toBe('master services agreement');
|
||||
expect(q(req.url).get('limit')).toBe('25');
|
||||
expect(q(req.url).get('offset')).toBe('25');
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: unwrap envelope', () => {
|
||||
it('returns the data payload of an envelope', () => {
|
||||
expect(unwrap({ data: { id: 'x' } })).toEqual({ id: 'x' });
|
||||
expect(unwrap({ data: [1, 2] })).toEqual([1, 2]);
|
||||
});
|
||||
it('returns a bare value unchanged', () => {
|
||||
expect(unwrap([1, 2])).toEqual([1, 2]);
|
||||
expect(unwrap({ id: 'y' })).toEqual({ id: 'y' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('api: parsePaging (body-based, not header)', () => {
|
||||
it('reads total_count and computes hasMore from offset+returned', () => {
|
||||
expect(parsePaging({ total_count: 137 }, 50, 0)).toEqual({ total: 137, hasMore: true });
|
||||
expect(parsePaging({ total_count: 40 }, 40, 0)).toEqual({ total: 40, hasMore: false });
|
||||
});
|
||||
it('honours an explicit overflow flag', () => {
|
||||
expect(parsePaging({ overflow: true }, 50, 0).hasMore).toBe(true);
|
||||
});
|
||||
it('honours a next cursor', () => {
|
||||
expect(parsePaging({ next: 'https://x/page2' }, 50, 0).hasMore).toBe(true);
|
||||
});
|
||||
it('reads count as a total fallback and defaults to 0', () => {
|
||||
expect(parsePaging({ count: 9 }, 9, 0).total).toBe(9);
|
||||
expect(parsePaging({}, 0, 0)).toEqual({ total: 0, hasMore: false });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -15,6 +15,7 @@ packages:
|
||||
- 'packages/gitlab'
|
||||
- 'packages/gworkspace'
|
||||
- 'packages/hubspot'
|
||||
- 'packages/imanage'
|
||||
- 'packages/jetbrains'
|
||||
- 'packages/jupyter'
|
||||
- 'packages/mcp'
|
||||
|
||||
Reference in New Issue
Block a user