Merge remote-tracking branch 'origin/feat/docusign'
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
# Hanzo AI for DocuSign
|
||||
|
||||
AI over agreements, right where they're signed. `@hanzo/docusign` adds four
|
||||
actions to any DocuSign envelope — **summarize**, **extract key terms**,
|
||||
**risk-flag clauses**, and **draft a memo** — and auto-summarizes every envelope
|
||||
the moment it completes.
|
||||
|
||||
Two surfaces, one shared core:
|
||||
|
||||
- **Panel** (`dist/index.html`) — an Extension App / linked-app page that opens
|
||||
with an envelope in context and runs the four actions over its agreement text.
|
||||
- **Service** (`dist/server.js`) — a small Node service that handles the DocuSign
|
||||
OAuth callback and the [Connect](https://developers.docusign.com/platform/webhooks/connect/)
|
||||
webhook: on `envelope → completed` it fetches the combined PDF, extracts the
|
||||
text, and summarizes it via [api.hanzo.ai](https://api.hanzo.ai).
|
||||
|
||||
Both are built on the shared Hanzo foundation — `@hanzo/ai` (the headless
|
||||
OpenAI-compatible model client) and `@hanzo/iam` (Hanzo identity) — and speak the
|
||||
gateway's `/v1` wire protocol, identical to `@hanzo/pdf`, `@hanzo/meetings`,
|
||||
`@hanzo/slack`, and `@hanzo/teams`.
|
||||
|
||||
> Analysis is informational, grounded strictly in the agreement text — **not legal
|
||||
> advice**. The system prompt forbids inventing parties, dates, or clauses.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
config.ts env boundary: gateway, model, DocuSign OAuth/API bases, HMAC, server config
|
||||
client.ts the createAiClient contract (thin @hanzo/ai; one-line re-export when published)
|
||||
docusign-oauth.ts Authorization Code Grant request shaping + userinfo/base_uri discovery (pure)
|
||||
docusign-api.ts eSignature REST v2.1 request wrappers + response parsing (pure)
|
||||
webhook.ts Connect HMAC verification + event routing (pure, node:crypto)
|
||||
pdf-text.ts PDF → text (pure itemsToText + injectable pdf.js loader)
|
||||
hanzo.ts document windowing/truncation + prompt assembly + the single `ask` path (pure)
|
||||
actions.ts the four AI actions as prompts over `ask` (one code path to the model)
|
||||
envelope.ts the analysis pipeline: fetch combined PDF → extract → window → action
|
||||
panel.ts pure panel logic (launch-context parse, text→pages)
|
||||
app.ts browser panel: DOM glue over the pure modules
|
||||
server.ts Node service: /oauth/install, /oauth/callback, /connect, /healthz
|
||||
index.html panel markup styles.css panel styles
|
||||
```
|
||||
|
||||
Everything logic-heavy is pure and unit-tested; `app.ts` (browser) and
|
||||
`server.ts` (Node) are the only impure entry points. The gateway is reached
|
||||
through **one** shape — `createAiClient().chat.completions.create()` — so swapping
|
||||
in the published `@hanzo/ai` headless client is a one-import change.
|
||||
|
||||
## Build & test
|
||||
|
||||
```bash
|
||||
pnpm --filter @hanzo/docusign install
|
||||
pnpm --filter @hanzo/docusign test # vitest — 103 tests
|
||||
pnpm --filter @hanzo/docusign typecheck # tsc --noEmit, clean
|
||||
pnpm --filter @hanzo/docusign build # esbuild -> dist/
|
||||
```
|
||||
|
||||
`build.js` bundles the panel (`app.js`), copies `index.html` + `styles.css`,
|
||||
emits the pdf.js worker (`pdf.worker.mjs`), and bundles the Node service
|
||||
(`server.js`).
|
||||
|
||||
## Register the DocuSign app
|
||||
|
||||
Do this once in the [DocuSign Developer / Admin console](https://admindemo.docusign.com)
|
||||
(demo) or the production Admin.
|
||||
|
||||
1. **Apps and Keys → Add App and Integration Key.** Note the **Integration Key**
|
||||
(this is your OAuth `client_id`).
|
||||
2. **Authentication:** choose **Authorization Code Grant**. Generate a **Secret
|
||||
Key** — this is `DOCUSIGN_CLIENT_SECRET`, held only by the service, never in
|
||||
the browser bundle.
|
||||
3. **Redirect URIs:** add your service callback, e.g.
|
||||
`https://docusign.hanzo.ai/oauth/callback` (`DOCUSIGN_REDIRECT_URI`).
|
||||
4. **OAuth scopes:** the app requests `signature openid`. `signature` grants the
|
||||
eSignature REST API (read envelopes/documents); `openid` lets
|
||||
`/oauth/userinfo` return the account list and each account's regional
|
||||
`base_uri` (we never hard-code a REST host — it's discovered at login).
|
||||
5. **Grant consent** once by visiting `GET /oauth/install` on the service; it
|
||||
redirects to DocuSign's consent screen, and the callback stores the account's
|
||||
token + `base_uri` for the webhook to use.
|
||||
|
||||
DocuSign environments:
|
||||
|
||||
| `DOCUSIGN_ENV` | Account server (OAuth) |
|
||||
| -------------- | ------------------------------ |
|
||||
| `demo` | `https://account-d.docusign.com` |
|
||||
| `production` | `https://account.docusign.com` |
|
||||
|
||||
The REST base (`https://<region>.docusign.net/restapi/v2.1`) comes from
|
||||
`/oauth/userinfo` per account — no configuration needed.
|
||||
|
||||
## Configure the Connect webhook (+ HMAC)
|
||||
|
||||
Connect is DocuSign's webhook. Under **Settings → Connect → Add Configuration →
|
||||
Custom**:
|
||||
|
||||
1. **URL to publish:** `https://docusign.hanzo.ai/connect` (the service's
|
||||
`POST /connect`).
|
||||
2. **Events:** enable **Envelope → Completed** (the service acts only on
|
||||
`completed`; every other status is acknowledged and ignored).
|
||||
3. **Data format:** JSON (the aggregate payload).
|
||||
4. **HMAC security** (required): under the configuration's **HMAC** section,
|
||||
**add a secret key**. Copy it into `DOCUSIGN_CONNECT_HMAC_KEY`.
|
||||
|
||||
### How verification works
|
||||
|
||||
DocuSign signs every Connect message. For each configured HMAC key it adds a
|
||||
header:
|
||||
|
||||
```
|
||||
X-DocuSign-Signature-1: base64( HMAC-SHA256( hmacKey, <raw request body> ) )
|
||||
```
|
||||
|
||||
The service recomputes the digest over the **raw** body bytes (never a
|
||||
re-serialized object — key order/whitespace would differ) and compares in
|
||||
**constant time** (`node:crypto` `timingSafeEqual`) against every
|
||||
`X-DocuSign-Signature-N` header. A message that fails is a **401** before any
|
||||
document is fetched — that is the entire trust boundary. This is covered by
|
||||
`test/webhook.test.ts` and verified end-to-end against the running service.
|
||||
|
||||
## Environment
|
||||
|
||||
The service reads only the environment; nothing is committed. In production these
|
||||
come from **KMS** (kms.hanzo.ai) synced via `KMSSecret` CRDs — never a checked-in
|
||||
env file.
|
||||
|
||||
| Variable | Required | Meaning |
|
||||
| -------- | -------- | ------- |
|
||||
| `DOCUSIGN_CLIENT_ID` | yes | Integration Key (OAuth client id; public) |
|
||||
| `DOCUSIGN_CLIENT_SECRET` | yes | OAuth secret key (server only) |
|
||||
| `DOCUSIGN_REDIRECT_URI` | yes | Registered OAuth callback URL |
|
||||
| `DOCUSIGN_CONNECT_HMAC_KEY` | yes | Connect HMAC secret (server only) |
|
||||
| `DOCUSIGN_ENV` | no | `demo` (default) or `production` |
|
||||
| `HANZO_API_KEY` | no | `hk-…` gateway bearer the webhook summarizes with; without it the webhook verifies + routes but skips summarization |
|
||||
| `PORT` | no | Listen port (default `8789`) |
|
||||
|
||||
The service fails fast (throws) at startup if any required secret is missing.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method + path | Purpose |
|
||||
| ------------- | ------- |
|
||||
| `GET /oauth/install` | 302 → DocuSign consent |
|
||||
| `GET /oauth/callback` | exchange code → token, discover `base_uri` via `/oauth/userinfo`, store the account authorization |
|
||||
| `POST /connect` | verify HMAC, then on `completed` fetch the combined PDF and summarize |
|
||||
| `GET /healthz` | readiness |
|
||||
|
||||
## The summarize / extract / risk flow
|
||||
|
||||
**In the panel** — the user (or DocuSign, via the launch URL's envelope context)
|
||||
provides the agreement text; a chip (or a freeform question) runs:
|
||||
|
||||
```
|
||||
agreement text → asPages → buildDocumentContext (windowed to the char budget,
|
||||
truncation reported honestly) → buildMessages (grounded system prompt, agreement
|
||||
fenced as data) → ask → api.hanzo.ai /v1/chat/completions → result
|
||||
```
|
||||
|
||||
**On completion** — the Connect webhook runs the same core, server-side:
|
||||
|
||||
```
|
||||
POST /connect → verify HMAC → routeEvent (completed?) → analyzeEnvelope:
|
||||
GET /envelopes/{env} (metadata: title, status)
|
||||
GET /envelopes/{env}/documents/combined (all documents as one PDF)
|
||||
extractPdfText (pdf.js) (per-page text)
|
||||
buildDocumentContext (window to budget)
|
||||
runAction('summarize') (→ api.hanzo.ai)
|
||||
→ summary (deliver hook: store in hanzoai/docdb, post back as an envelope
|
||||
custom field, or notify the sender — a per-deployment choice)
|
||||
```
|
||||
|
||||
The four actions are the **same** `runAction` id in both surfaces — the chip a
|
||||
user clicks and the webhook auto-summary share one code path to the model.
|
||||
|
||||
## Deploy
|
||||
|
||||
The panel is static; the service is a small container. Per the Hanzo stack:
|
||||
|
||||
- **Panel** → hosted over **hanzoai/static** behind **hanzoai/ingress** at
|
||||
`docusign.hanzo.ai` (serve `dist/index.html`, `app.js`, `styles.css`,
|
||||
`pdf.worker.mjs`).
|
||||
- **Service** → a container running `node dist/server.js`, image
|
||||
`ghcr.io/hanzoai/docusign:<tag>` (built in CI/CD, `--platform linux/amd64`),
|
||||
behind `hanzoai/ingress`, with secrets from KMS via `KMSSecret`. Point the
|
||||
DocuSign redirect URI and the Connect URL at `docusign.hanzo.ai`.
|
||||
|
||||
No nginx, no caddy — `hanzoai/ingress` + `hanzoai/static` only.
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
// Build @hanzo/docusign into dist/: bundle the web panel (app.ts) as ESM for the
|
||||
// browser, copy index.html (with its entry script stamped) + styles.css, emit
|
||||
// the pdf.js worker the panel needs, and bundle the OAuth + Connect webhook
|
||||
// server for Node. No framework — esbuild + Node stdlib only.
|
||||
//
|
||||
// node build.js → production build
|
||||
// node build.js --watch → rebuild on change
|
||||
// HANZO_DOCUSIGN_BASE=https://localhost:8443 node build.js → dev base (informational)
|
||||
//
|
||||
// Output layout:
|
||||
// dist/index.html dist/app.js dist/styles.css dist/pdf.worker.mjs (panel)
|
||||
// dist/server.js (service)
|
||||
//
|
||||
// The panel is HOSTED over hanzoai/static behind hanzoai/ingress at
|
||||
// docusign.hanzo.ai; the server runs as a small service (OAuth callback +
|
||||
// Connect webhook). 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';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const BASE = (process.env.HANZO_DOCUSIGN_BASE || 'https://docusign.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'));
|
||||
|
||||
// Emit the pdf.js worker the panel loads (pdf-text points import.meta.url at
|
||||
// ./pdf.worker.mjs when it configures the worker in-browser).
|
||||
const worker = require.resolve('pdfjs-dist/build/pdf.worker.mjs');
|
||||
copyFileSync(worker, join(dist, 'pdf.worker.mjs'));
|
||||
|
||||
// Bundle the server as a Node ESM binary. pdfjs-dist is left external so the
|
||||
// server loads the installed package at runtime (it is large and pulls native
|
||||
// paths esbuild should not inline); everything else is our pure stdlib code.
|
||||
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,
|
||||
external: ['pdfjs-dist'],
|
||||
banner: { js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);" },
|
||||
logLevel: 'info',
|
||||
});
|
||||
await serverCtx.rebuild();
|
||||
|
||||
console.log(`Hanzo AI for DocuSign 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/docusign",
|
||||
"version": "0.1.0",
|
||||
"description": "Hanzo AI for DocuSign — AI over agreements and envelopes: summarize, extract key terms, risk-flag clauses, and draft memos. An Extension App panel plus a Connect webhook that auto-summarizes completed envelopes, 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.1.1",
|
||||
"@hanzo/iam": "0.13.2",
|
||||
"pdfjs-dist": "4.10.38"
|
||||
},
|
||||
"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",
|
||||
"docusign",
|
||||
"esignature",
|
||||
"agreements",
|
||||
"contracts",
|
||||
"ai",
|
||||
"connect-webhook"
|
||||
],
|
||||
"author": "Hanzo AI",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/extension.git",
|
||||
"directory": "packages/docusign"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// The four AI actions over an agreement — summarize, extract key terms,
|
||||
// risk-flag clauses, draft a memo. Each is a prompt template applied to the
|
||||
// windowed agreement 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,
|
||||
// the picker, and the webhook all resolve an id here and call `ask`. No action
|
||||
// speaks to the gateway directly.
|
||||
|
||||
import { ask, type AgreementMeta, type AskOptions, type DocumentContext } from './hanzo.js';
|
||||
|
||||
// The action catalog. `id` is the stable key the panel's chips and the webhook
|
||||
// use; `label` is the button text; `prompt` is the instruction handed to the
|
||||
// model as the task, over the fenced agreement text. Prompts are specific and
|
||||
// output-shaped (bullet lists, tables) so results are consistent and parseable.
|
||||
export const ACTIONS = {
|
||||
summarize: {
|
||||
label: 'Summarize',
|
||||
prompt:
|
||||
'Summarize this agreement for a busy reviewer. Cover: what it is and who ' +
|
||||
'the parties are, the core commercial terms, the term and termination, and ' +
|
||||
'anything a signer must know before signing. Use short paragraphs or ' +
|
||||
'bullets. No preamble.',
|
||||
},
|
||||
extractTerms: {
|
||||
label: 'Extract key terms',
|
||||
prompt:
|
||||
'Extract the key terms from this agreement as a structured list. Include, ' +
|
||||
'where present: the parties; the effective date and any expiry/renewal ' +
|
||||
'date; the term length; payment terms (amounts, schedule, currency); the ' +
|
||||
'main obligations of each party; termination rights; and the governing ' +
|
||||
'law/jurisdiction. Format as "Term: value" lines grouped under headings. ' +
|
||||
'If a term is not stated, write "not specified" rather than guessing.',
|
||||
},
|
||||
riskFlags: {
|
||||
label: 'Risk flags',
|
||||
prompt:
|
||||
'Review this agreement and flag clauses that are unusual, one-sided, or ' +
|
||||
'carry material risk for a signer. For each: name the clause/section, quote ' +
|
||||
'or paraphrase the risky part, and explain the risk in plain language ' +
|
||||
'(1-2 sentences). Order by severity (highest first). If nothing stands out, ' +
|
||||
'say so. Do not invent clauses that are not in the text.',
|
||||
},
|
||||
draftMemo: {
|
||||
label: 'Draft memo',
|
||||
prompt:
|
||||
'Write a short cover memo about this agreement for an internal reviewer or ' +
|
||||
'approver. Structure: a one-line summary; the key terms in brief; the top ' +
|
||||
'risks or open items; and a clear recommendation (approve / approve with ' +
|
||||
'changes / do not sign) with a one-sentence rationale. Return only the memo ' +
|
||||
'body, ready to paste.',
|
||||
},
|
||||
} 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.
|
||||
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.
|
||||
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 agreement via `ask`. This is the one code
|
||||
// path from an action id to the model — the chips, the webhook auto-summarize,
|
||||
// and any programmatic caller share it. 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?: AgreementMeta,
|
||||
opts: AskOptions = {},
|
||||
): Promise<string> {
|
||||
return ask(actionPrompt(id), ctx, meta, opts);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// The DocuSign panel glue: the Extension-App / linked-app page that opens with an
|
||||
// envelope in context and drives the four AI actions over its agreement. All the
|
||||
// logic-heavy work (action prompts, document windowing, chat shaping, auth) lives
|
||||
// in its own tested modules; this file binds them to the DOM. It is the one
|
||||
// impure, browser-only entry point.
|
||||
//
|
||||
// How the panel gets the agreement text: DocuSign launches an Extension App /
|
||||
// linked app with the envelope in the URL. The panel reads that context
|
||||
// (parseLaunchContext), and the user pastes or the host injects the agreement
|
||||
// text into the box — the panel never holds the DocuSign secret (document bytes
|
||||
// are fetched server-side by the Connect service). Actions then run over the
|
||||
// windowed text straight against the api.hanzo.ai gateway with the pasted Hanzo
|
||||
// key. The context parsing + text→pages logic is pure (panel.ts), unit-tested.
|
||||
|
||||
import { actionList, isActionId, runAction } from './actions.js';
|
||||
import { ask as askHanzo, buildDocumentContext, contextNote, listModels } from './hanzo.js';
|
||||
import { DEFAULT_MODEL } from './config.js';
|
||||
import { getApiKey, setApiKey, hasApiKey, bearer, validateKey } from './auth.js';
|
||||
import { asPages, panelTitle, parseLaunchContext } from './panel.js';
|
||||
|
||||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const ctx = parseLaunchContext(window.location.search);
|
||||
|
||||
const titleEl = $('agreement-title');
|
||||
const docEl = $<HTMLTextAreaElement>('doc');
|
||||
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');
|
||||
|
||||
titleEl.textContent = panelTitle(ctx);
|
||||
|
||||
apiKeyEl.value = getApiKey();
|
||||
reflectAuth();
|
||||
void populateModels();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
runBtn.onclick = () => {
|
||||
const prompt = promptEl.value.trim();
|
||||
if (prompt) void ask(prompt);
|
||||
};
|
||||
stopBtn.onclick = () => controller?.abort();
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
// run executes one of the four named actions over the current agreement text.
|
||||
async function run(actionId: string): Promise<void> {
|
||||
if (!isActionId(actionId)) return;
|
||||
await execute((docCtx, meta, opts) => runAction(actionId, docCtx, meta, opts));
|
||||
}
|
||||
|
||||
// ask executes a freeform question over the agreement.
|
||||
async function ask(prompt: string): Promise<void> {
|
||||
await execute((docCtx, meta, opts) => askHanzo(prompt, docCtx, meta, opts));
|
||||
}
|
||||
|
||||
// execute is the shared runner: window the pasted text, call the model, show
|
||||
// the result. Both the chips and freeform ask funnel through here (one path).
|
||||
async function execute(
|
||||
call: (
|
||||
docCtx: ReturnType<typeof buildDocumentContext>,
|
||||
meta: { title?: string; envelopeId?: string },
|
||||
opts: { token: string; model: string; signal: AbortSignal },
|
||||
) => Promise<string>,
|
||||
): Promise<void> {
|
||||
const pages = asPages(docEl.value);
|
||||
if (pages.length === 0) {
|
||||
setStatus('Paste the agreement text (or open from DocuSign with an envelope).', 'warn');
|
||||
return;
|
||||
}
|
||||
const docCtx = buildDocumentContext(pages);
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
setBusy(true);
|
||||
setStatus(contextNote(docCtx));
|
||||
outputEl.value = '';
|
||||
try {
|
||||
const text = await call(
|
||||
docCtx,
|
||||
{ title: ctx.title || undefined, envelopeId: ctx.envelopeId || undefined },
|
||||
{ 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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
for (const c of Array.from(chipRow.querySelectorAll('button'))) (c as HTMLButtonElement).disabled = b;
|
||||
}
|
||||
|
||||
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. The DocuSign Extension
|
||||
// App 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/meetings and @hanzo/pdf exactly (one way
|
||||
// to hold a key).
|
||||
//
|
||||
// The DocuSign access token (for reading envelopes/documents) is a SEPARATE
|
||||
// credential minted server-side by the OAuth flow; the panel receives an
|
||||
// envelope's already-extracted document text from its host, so it never holds
|
||||
// the DocuSign 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,211 @@
|
||||
/**
|
||||
* Headless OpenAI-compatible Hanzo client — the `createAiClient` contract.
|
||||
*
|
||||
* The app talks to the model gateway through ONE shape:
|
||||
*
|
||||
* ```ts
|
||||
* const ai = createAiClient({ token });
|
||||
* const res = await ai.chat.completions.create({ model, messages });
|
||||
* const models = await ai.models.list();
|
||||
* ```
|
||||
*
|
||||
* Defaults to `https://api.hanzo.ai` and `/v1/...`. Non-streaming: the DocuSign
|
||||
* panel renders the FINAL text, and the webhook stores a finished summary — no
|
||||
* surface streams. Pure over `fetch` (injectable), so request shaping and
|
||||
* response parsing are unit-testable without a network.
|
||||
*
|
||||
* This mirrors the `@hanzo/ai` headless-client surface so the rest of the app is
|
||||
* written against the client interface, not against raw `fetch`. Swapping in the
|
||||
* published `@hanzo/ai` `createAiClient` is a one-import change — the call sites
|
||||
* do not move. (The npm `@hanzo/ai@latest` is currently the React UI, not the
|
||||
* headless client, so we ship this thin implementation of the exact contract,
|
||||
* identical to @hanzo/slack and @hanzo/teams.)
|
||||
*/
|
||||
|
||||
import { HANZO_API_BASE_URL } from './config.js';
|
||||
|
||||
/** A single chat message in the OpenAI-compatible schema. */
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Parameters for a chat completion. `model` and `messages` are required. */
|
||||
export interface ChatCompletionParams {
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
/** Sampling temperature. Omitted from the wire body when undefined. */
|
||||
temperature?: number;
|
||||
/** Hard cap on generated tokens. Omitted when undefined. */
|
||||
max_tokens?: number;
|
||||
/** Abort the request. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** OpenAI-compatible completion response (only the fields we read). */
|
||||
export interface ChatCompletion {
|
||||
id?: string;
|
||||
model?: string;
|
||||
choices: Array<{
|
||||
index?: number;
|
||||
message?: { role: string; content: string };
|
||||
text?: string;
|
||||
finish_reason?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** A model entry from `/v1/models`. */
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
/** Friendly display name when the gateway provides one. */
|
||||
fullName?: string;
|
||||
owned_by?: string;
|
||||
}
|
||||
|
||||
/** Options for {@link createAiClient}. */
|
||||
export interface AiClientOptions {
|
||||
/**
|
||||
* Bearer credential: an `hk-…` Hanzo API key or an IAM access token. Empty
|
||||
* means anonymous — the gateway serves its public/limited models.
|
||||
*/
|
||||
token?: string;
|
||||
/** Gateway base URL. Defaults to `https://api.hanzo.ai`. */
|
||||
baseURL?: string;
|
||||
/** Injected fetch (tests). Defaults to global `fetch`. */
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
/** The headless client surface. */
|
||||
export interface AiClient {
|
||||
chat: {
|
||||
completions: {
|
||||
create(params: ChatCompletionParams): Promise<ChatCompletion>;
|
||||
};
|
||||
};
|
||||
models: {
|
||||
list(signal?: AbortSignal): Promise<ModelInfo[]>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the JSON body sent to `/v1/chat/completions`. Pure — unit-tested. */
|
||||
export function chatRequestBody(params: ChatCompletionParams): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
model: params.model,
|
||||
messages: params.messages,
|
||||
stream: false,
|
||||
};
|
||||
if (params.temperature !== undefined) body.temperature = params.temperature;
|
||||
if (params.max_tokens !== undefined) body.max_tokens = params.max_tokens;
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract assistant text from an OpenAI-compatible completion, tolerating the
|
||||
* shapes the gateway returns (choices[].message.content or choices[].text).
|
||||
* Throws on an error payload or empty content so callers surface the real
|
||||
* gateway message. Pure — unit-tested.
|
||||
*/
|
||||
export function extractContent(data: unknown): string {
|
||||
const d = data as {
|
||||
error?: unknown;
|
||||
choices?: Array<{ message?: { content?: string }; text?: string }>;
|
||||
content?: string;
|
||||
};
|
||||
if (d && d.error) {
|
||||
const e = d.error as { message?: string } | string;
|
||||
const msg = typeof e === 'string' ? e : e?.message || 'unknown error';
|
||||
throw new Error(`Hanzo API error: ${msg}`);
|
||||
}
|
||||
const choice = d?.choices?.[0];
|
||||
const content = choice?.message?.content ?? choice?.text ?? d?.content ?? '';
|
||||
if (typeof content !== 'string' || content === '') {
|
||||
throw new Error('Hanzo API returned no content');
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `/v1/models` into a {@link ModelInfo}[]. Reads the OpenAI-compatible
|
||||
* `data[]`, falling back to `models[]` or a bare array. Pure — unit-tested.
|
||||
*/
|
||||
export function parseModels(data: unknown): ModelInfo[] {
|
||||
const d = data as { data?: unknown[]; models?: unknown[] };
|
||||
const items: unknown[] = Array.isArray(d?.data)
|
||||
? d.data
|
||||
: Array.isArray(d?.models)
|
||||
? d.models
|
||||
: Array.isArray(data)
|
||||
? (data as unknown[])
|
||||
: [];
|
||||
const out: ModelInfo[] = [];
|
||||
for (const item of items) {
|
||||
if (typeof item === 'string') {
|
||||
out.push({ id: item });
|
||||
continue;
|
||||
}
|
||||
const m = item as { id?: unknown; fullName?: unknown; owned_by?: unknown };
|
||||
if (typeof m?.id === 'string' && m.id.length > 0) {
|
||||
out.push({
|
||||
id: m.id,
|
||||
fullName: typeof m.fullName === 'string' ? m.fullName : undefined,
|
||||
owned_by: typeof m.owned_by === 'string' ? m.owned_by : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function authHeaders(token?: string): Record<string, string> {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a headless Hanzo AI client. Non-streaming completions + model listing
|
||||
* over the OpenAI-compatible `/v1` gateway.
|
||||
*/
|
||||
export function createAiClient(opts: AiClientOptions = {}): AiClient {
|
||||
const baseURL = (opts.baseURL ?? HANZO_API_BASE_URL).replace(/\/+$/, '');
|
||||
const doFetch = opts.fetch ?? fetch;
|
||||
const token = opts.token;
|
||||
|
||||
return {
|
||||
chat: {
|
||||
completions: {
|
||||
async create(params: ChatCompletionParams): Promise<ChatCompletion> {
|
||||
const resp = await doFetch(`${baseURL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
|
||||
body: JSON.stringify(chatRequestBody(params)),
|
||||
signal: params.signal,
|
||||
});
|
||||
const text = await resp.text();
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`Hanzo API ${resp.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = (data as { error?: { message?: string } | string })?.error;
|
||||
const m =
|
||||
(typeof err === 'string' ? err : err?.message) || `HTTP ${resp.status}`;
|
||||
throw new Error(`Hanzo API ${resp.status}: ${m}`);
|
||||
}
|
||||
return data as ChatCompletion;
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
async list(signal?: AbortSignal): Promise<ModelInfo[]> {
|
||||
const resp = await doFetch(`${baseURL}/v1/models`, {
|
||||
headers: authHeaders(token),
|
||||
signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Hanzo API ${resp.status}: model list failed`);
|
||||
}
|
||||
return parseModels(await resp.json());
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// DocuSign config — the api.hanzo.ai model gateway, the default model, the
|
||||
// pasted-key storage for the web panel, and the server-side secret set
|
||||
// (DocuSign OAuth + Connect webhook HMAC). Endpoints and the bearer choice
|
||||
// mirror @hanzo/meetings and @hanzo/pdf so the productivity suite stays DRY;
|
||||
// the agreement-specific pieces (document-text windowing, the four AI actions)
|
||||
// live in hanzo.ts / actions.ts, not here.
|
||||
|
||||
// ---- 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).
|
||||
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-docusign';
|
||||
|
||||
// localStorage key for the pasted Hanzo API key (`hk-…`) in the web panel. The
|
||||
// Extension-App panel is a static web page (iframe), not an Office host with
|
||||
// roamingSettings, so the zero-setup credential lives in localStorage — same as
|
||||
// @hanzo/pdf and @hanzo/meetings.
|
||||
export const APIKEY_STORAGE_KEY = 'hanzo.docusign.apiKey';
|
||||
|
||||
// Agreement text budget. A long contract runs to tens of thousands of words;
|
||||
// the whole thing won't fit a model window and shouldn't be sent. This caps the
|
||||
// characters of 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`;
|
||||
}
|
||||
|
||||
// ---- DocuSign OAuth + API bases -------------------------------------------
|
||||
//
|
||||
// DocuSign has two OAuth environments (the "account server") and per-account
|
||||
// regional REST bases returned at login via /oauth/userinfo. We never hard-code
|
||||
// a REST host: the base_uri for the signed-in account comes from userinfo and is
|
||||
// stored with the account. The account server, however, is a fixed pair.
|
||||
|
||||
// The DocuSign account server (OAuth). Demo/developer vs production.
|
||||
export const DOCUSIGN_ACCOUNT_SERVER = {
|
||||
demo: 'https://account-d.docusign.com',
|
||||
production: 'https://account.docusign.com',
|
||||
} as const;
|
||||
|
||||
// Which environment is a DocuSign env value.
|
||||
export type DocusignEnv = keyof typeof DOCUSIGN_ACCOUNT_SERVER;
|
||||
|
||||
// accountServer resolves the OAuth account-server origin for an environment.
|
||||
export function accountServer(env: DocusignEnv): string {
|
||||
return DOCUSIGN_ACCOUNT_SERVER[env];
|
||||
}
|
||||
|
||||
// The eSignature REST API version segment. v2.1 is current; we never bump to a
|
||||
// speculative v2.2 — one version, forward only.
|
||||
export const ESIGN_API_VERSION = 'v2.1';
|
||||
|
||||
// ---- Server-side configuration (DocuSign OAuth + Connect webhook) ---------
|
||||
//
|
||||
// These are read from the environment by src/server.ts. They NEVER reach the
|
||||
// browser bundle: the integration key (client id) is public, but the secret key
|
||||
// and the Connect HMAC key are server-only and are validated to be present
|
||||
// before the server will start (readServerConfig throws on a missing secret).
|
||||
|
||||
export interface ServerConfig {
|
||||
/** DocuSign integration key / OAuth client id (public). */
|
||||
docusignClientId: string;
|
||||
/** DocuSign OAuth secret key (SERVER ONLY — token exchange). */
|
||||
docusignClientSecret: string;
|
||||
/** OAuth redirect registered on the app (e.g. https://docusign.hanzo.ai/oauth/callback). */
|
||||
docusignRedirectUri: string;
|
||||
/** DocuSign environment: 'demo' (account-d) or 'production' (account). */
|
||||
docusignEnv: DocusignEnv;
|
||||
/**
|
||||
* Connect HMAC key — verifies the X-DocuSign-Signature-* header on every
|
||||
* event. Configured in the Connect configuration's HMAC section. Server only.
|
||||
*/
|
||||
connectHmacKey: string;
|
||||
/**
|
||||
* Hanzo API key the server uses to summarize a completed envelope. The webhook
|
||||
* runs with no user in the loop, so it needs its own bearer. Optional: without
|
||||
* it, the server verifies + routes events but cannot summarize (it logs and
|
||||
* skips), which readServerConfig reports honestly.
|
||||
*/
|
||||
hanzoApiKey: string;
|
||||
/** Listen port. */
|
||||
port: number;
|
||||
}
|
||||
|
||||
// The OAuth scopes the app requests. `signature` is the eSignature REST scope;
|
||||
// `openid` is what /oauth/userinfo needs to return the account list + base_uri.
|
||||
export const OAUTH_SCOPES = ['signature', 'openid'] as const;
|
||||
|
||||
// isDocusignEnv narrows an arbitrary string to a DocusignEnv, defaulting unknown
|
||||
// values to 'demo' (the safe, non-production sandbox). Boundary guard.
|
||||
export function isDocusignEnv(v: string | undefined): v is DocusignEnv {
|
||||
return v === 'demo' || v === 'production';
|
||||
}
|
||||
|
||||
// readServerConfig fails fast (throws) if a required DocuSign secret is missing —
|
||||
// a server that can neither exchange OAuth codes nor verify Connect events must
|
||||
// not pretend to start. hanzoApiKey is the one optional field (see above). Pure
|
||||
// given an env map, so it is unit-tested without touching process.env.
|
||||
export function readServerConfig(env: Record<string, string | undefined>): ServerConfig {
|
||||
const docusignClientId = env.DOCUSIGN_CLIENT_ID;
|
||||
const docusignClientSecret = env.DOCUSIGN_CLIENT_SECRET;
|
||||
const docusignRedirectUri = env.DOCUSIGN_REDIRECT_URI;
|
||||
const connectHmacKey = env.DOCUSIGN_CONNECT_HMAC_KEY;
|
||||
const missing: string[] = [];
|
||||
if (!docusignClientId) missing.push('DOCUSIGN_CLIENT_ID');
|
||||
if (!docusignClientSecret) missing.push('DOCUSIGN_CLIENT_SECRET');
|
||||
if (!docusignRedirectUri) missing.push('DOCUSIGN_REDIRECT_URI');
|
||||
if (!connectHmacKey) missing.push('DOCUSIGN_CONNECT_HMAC_KEY');
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required environment: ${missing.join(', ')}`);
|
||||
}
|
||||
return {
|
||||
docusignClientId: docusignClientId!,
|
||||
docusignClientSecret: docusignClientSecret!,
|
||||
docusignRedirectUri: docusignRedirectUri!,
|
||||
docusignEnv: isDocusignEnv(env.DOCUSIGN_ENV) ? env.DOCUSIGN_ENV : 'demo',
|
||||
connectHmacKey: connectHmacKey!,
|
||||
hanzoApiKey: env.HANZO_API_KEY || '',
|
||||
port: Number(env.PORT) || 8789,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// DocuSign eSignature REST API — pure request shaping + response parsing. Every
|
||||
// function returns a PreparedRequest (url + headers) or parses a response body;
|
||||
// none opens a socket, so the whole API surface is unit-testable without a
|
||||
// network. server.ts / app.ts are the thin glue that fetch these shapes with a
|
||||
// Bearer access token.
|
||||
//
|
||||
// The REST root is `${base_uri}/restapi/v2.1` (see docusign-oauth.apiBaseUrl),
|
||||
// where base_uri comes from /oauth/userinfo for the signed-in account. All paths
|
||||
// below are relative to that root. Docs:
|
||||
// developers.docusign.com/docs/esign-rest-api/reference/envelopes/.
|
||||
|
||||
// A prepared GET: the url + headers a single fetch needs. Bearer-only — the
|
||||
// eSignature API is authorized by the OAuth access token.
|
||||
export interface PreparedGet {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
// bearer builds the Authorization header for an eSignature request.
|
||||
function bearer(accessToken: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${accessToken}` };
|
||||
}
|
||||
|
||||
// The special documentId keyword that returns ALL documents in an envelope
|
||||
// merged into one PDF (plus the Certificate of Completion when the account
|
||||
// enables it). We summarize the whole agreement, so this is the default target.
|
||||
export const COMBINED_DOCUMENT_ID = 'combined';
|
||||
|
||||
// getEnvelope — envelope metadata (status, subject, dates, sender). Used to
|
||||
// title the summary and to confirm an envelope is complete before acting.
|
||||
export function getEnvelope(apiBase: string, accountId: string, envelopeId: string, accessToken: string): PreparedGet {
|
||||
return {
|
||||
url: `${apiBase}/accounts/${enc(accountId)}/envelopes/${enc(envelopeId)}`,
|
||||
headers: bearer(accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// listDocuments — the documents in an envelope (id, name, type, order). The
|
||||
// panel shows this list; the webhook uses it to know what it's summarizing.
|
||||
export function listDocuments(apiBase: string, accountId: string, envelopeId: string, accessToken: string): PreparedGet {
|
||||
return {
|
||||
url: `${apiBase}/accounts/${enc(accountId)}/envelopes/${enc(envelopeId)}/documents`,
|
||||
headers: bearer(accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// getDocument — the PDF bytes for one document, or the special keywords
|
||||
// (`combined`, `archive`, `certificate`). Returns application/pdf; the caller
|
||||
// reads the body as an ArrayBuffer and extracts text. documentId defaults to
|
||||
// `combined` (the whole agreement as one PDF).
|
||||
export function getDocument(
|
||||
apiBase: string,
|
||||
accountId: string,
|
||||
envelopeId: string,
|
||||
accessToken: string,
|
||||
documentId: string = COMBINED_DOCUMENT_ID,
|
||||
): PreparedGet {
|
||||
return {
|
||||
url: `${apiBase}/accounts/${enc(accountId)}/envelopes/${enc(envelopeId)}/documents/${enc(documentId)}`,
|
||||
headers: { ...bearer(accessToken), Accept: 'application/pdf' },
|
||||
};
|
||||
}
|
||||
|
||||
// listEnvelopes — envelopes changed since a date (ISO 8601). The panel's picker
|
||||
// uses this to let a user choose which agreement to run against when it wasn't
|
||||
// launched with an envelope in context. from_date is required by DocuSign when
|
||||
// no explicit envelope/folder filter is given.
|
||||
export function listEnvelopes(
|
||||
apiBase: string,
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
fromDate: string,
|
||||
): PreparedGet {
|
||||
const q = new URLSearchParams({ from_date: fromDate }).toString();
|
||||
return {
|
||||
url: `${apiBase}/accounts/${enc(accountId)}/envelopes?${q}`,
|
||||
headers: bearer(accessToken),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Response parsing -----------------------------------------------------
|
||||
|
||||
// One document as it appears in a listDocuments response.
|
||||
export interface EnvelopeDocument {
|
||||
documentId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// parseDocuments reads a listDocuments body into a typed list, sorted by the
|
||||
// `order` field DocuSign supplies (the display order in the envelope). The
|
||||
// Certificate of Completion arrives with documentId "certificate"; we keep it
|
||||
// (it carries the audit trail) but it sorts last by its non-numeric order.
|
||||
// Pure — unit-tested with plain objects.
|
||||
export function parseDocuments(data: any): EnvelopeDocument[] {
|
||||
const rawDocs = Array.isArray(data?.envelopeDocuments) ? data.envelopeDocuments : [];
|
||||
const docs: EnvelopeDocument[] = rawDocs.map((d: any) => ({
|
||||
documentId: String(d?.documentId ?? ''),
|
||||
name: String(d?.name ?? ''),
|
||||
type: String(d?.type ?? ''),
|
||||
order: Number.parseInt(String(d?.order ?? ''), 10) || 0,
|
||||
}));
|
||||
return docs
|
||||
.filter((d) => d.documentId.length > 0)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
// Envelope metadata we surface.
|
||||
export interface EnvelopeInfo {
|
||||
envelopeId: string;
|
||||
status: string;
|
||||
emailSubject: string;
|
||||
sentDateTime: string;
|
||||
completedDateTime: string;
|
||||
}
|
||||
|
||||
// parseEnvelope reads a getEnvelope body into a typed summary. Missing fields
|
||||
// become '' rather than undefined so the UI/prompt renders predictably.
|
||||
export function parseEnvelope(data: any): EnvelopeInfo {
|
||||
return {
|
||||
envelopeId: String(data?.envelopeId ?? ''),
|
||||
status: String(data?.status ?? ''),
|
||||
emailSubject: String(data?.emailSubject ?? ''),
|
||||
sentDateTime: String(data?.sentDateTime ?? ''),
|
||||
completedDateTime: String(data?.completedDateTime ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
// enc encodes a single path segment. Account/envelope/document ids are GUIDs or
|
||||
// short keywords, but we encode defensively at this boundary so a stray
|
||||
// character can never break the path (or worse, traverse it).
|
||||
function enc(segment: string): string {
|
||||
return encodeURIComponent(segment);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// DocuSign OAuth (Confidential Authorization Code Grant) — pure request shaping.
|
||||
// The secret key is held by the server (server.ts) and never reaches the
|
||||
// browser; these functions build the exact URL/headers/body of each OAuth call
|
||||
// so the wire shape is unit-testable without a network round-trip. The token
|
||||
// exchange and userinfo GET are each one fetch in the server.
|
||||
//
|
||||
// DocuSign uses HTTP Basic auth (base64 integration_key:secret_key) on the token
|
||||
// endpoint and form-encoded bodies — the OAuth2 confidential-client shape,
|
||||
// documented at developers.docusign.com/platform/auth/confidential-authcode-get-token.
|
||||
// The account server is account-d.docusign.com (demo) / account.docusign.com
|
||||
// (prod); the per-account REST base_uri is discovered from /oauth/userinfo.
|
||||
|
||||
import { accountServer, ESIGN_API_VERSION, type DocusignEnv } from './config.js';
|
||||
|
||||
// authorizeUrl is where the install flow sends the user's browser to grant the
|
||||
// app. `scope` is space-joined (the OAuth2 convention), `state` is the CSRF
|
||||
// token the server generates and re-checks on callback.
|
||||
export function authorizeUrl(args: {
|
||||
env: DocusignEnv;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
scopes: readonly string[];
|
||||
state: string;
|
||||
}): string {
|
||||
const q = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
scope: args.scopes.join(' '),
|
||||
client_id: args.clientId,
|
||||
redirect_uri: args.redirectUri,
|
||||
state: args.state,
|
||||
});
|
||||
return `${accountServer(args.env)}/oauth/auth?${q.toString()}`;
|
||||
}
|
||||
|
||||
// A prepared HTTP request: the pieces a single fetch needs. Pure output so a
|
||||
// test asserts the Basic header + form body without opening a socket.
|
||||
export interface PreparedRequest {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
// basicAuth builds the Authorization header value for the token endpoint. The
|
||||
// secret rides only here, never in the body or the URL.
|
||||
export function basicAuth(clientId: string, clientSecret: string): string {
|
||||
return 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||
}
|
||||
|
||||
// tokenExchange builds the code→token request against the account server's
|
||||
// /oauth/token. grant_type=authorization_code + the code; the redirect_uri is
|
||||
// NOT part of DocuSign's token body (unlike some providers) — the code alone,
|
||||
// bound to the app by the Basic credential, is exchanged.
|
||||
export function tokenExchange(args: {
|
||||
env: DocusignEnv;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
code: string;
|
||||
}): PreparedRequest {
|
||||
return {
|
||||
url: `${accountServer(args.env)}/oauth/token`,
|
||||
headers: {
|
||||
Authorization: basicAuth(args.clientId, args.clientSecret),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: args.code,
|
||||
}).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// refreshExchange builds the refresh-token→token request. Same account server,
|
||||
// grant_type=refresh_token. A long-lived Connect deployment refreshes the
|
||||
// server's stored token this way rather than re-consenting.
|
||||
export function refreshExchange(args: {
|
||||
env: DocusignEnv;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
refreshToken: string;
|
||||
}): PreparedRequest {
|
||||
return {
|
||||
url: `${accountServer(args.env)}/oauth/token`,
|
||||
headers: {
|
||||
Authorization: basicAuth(args.clientId, args.clientSecret),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: args.refreshToken,
|
||||
}).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// userInfoUrl is the account-server endpoint that returns the caller's accounts
|
||||
// and — critically — each account's REST base_uri. We never hard-code a REST
|
||||
// host; it is discovered here after login.
|
||||
export function userInfoUrl(env: DocusignEnv): string {
|
||||
return `${accountServer(env)}/oauth/userinfo`;
|
||||
}
|
||||
|
||||
// The token response we care about. DocuSign returns access_token (+
|
||||
// refresh_token, expires_in, token_type). parseTokenResponse validates the one
|
||||
// field we must have and surfaces DocuSign's own error otherwise.
|
||||
export interface DocusignTokenSet {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
export function parseTokenResponse(data: any): DocusignTokenSet {
|
||||
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(`DocuSign OAuth error: ${reason}`);
|
||||
}
|
||||
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
|
||||
throw new Error('DocuSign OAuth response missing access_token');
|
||||
}
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_in: data.expires_in,
|
||||
token_type: data.token_type,
|
||||
};
|
||||
}
|
||||
|
||||
// One account entry from /oauth/userinfo. base_uri is the regional REST host
|
||||
// (e.g. https://demo.docusign.net) WITHOUT the /restapi path — apiBaseUrl adds
|
||||
// it. account_id is the GUID used in every eSignature REST path.
|
||||
export interface DocusignAccount {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
baseUri: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
// The resolved caller identity: who they are plus their accounts.
|
||||
export interface DocusignUserInfo {
|
||||
sub: string;
|
||||
name: string;
|
||||
email: string;
|
||||
accounts: DocusignAccount[];
|
||||
}
|
||||
|
||||
// parseUserInfo reads /oauth/userinfo into a typed identity. DocuSign's field
|
||||
// names are snake_case (account_id, base_uri, is_default); we normalize to
|
||||
// camelCase at this boundary so nothing downstream touches the wire shape.
|
||||
// Throws on a payload with no accounts (a signed-in user always has at least
|
||||
// one) so the caller learns immediately rather than failing later on an empty
|
||||
// base_uri.
|
||||
export function parseUserInfo(data: any): DocusignUserInfo {
|
||||
if (!data || typeof data !== 'object') throw new Error('empty userinfo response');
|
||||
if (data.error) {
|
||||
throw new Error(`DocuSign userinfo error: ${data.error_description || data.error}`);
|
||||
}
|
||||
const rawAccounts = Array.isArray(data.accounts) ? data.accounts : [];
|
||||
const accounts: DocusignAccount[] = rawAccounts
|
||||
.map((a: any) => ({
|
||||
accountId: String(a?.account_id ?? ''),
|
||||
accountName: String(a?.account_name ?? ''),
|
||||
baseUri: String(a?.base_uri ?? '').replace(/\/+$/, ''),
|
||||
isDefault: a?.is_default === true || a?.is_default === 'true',
|
||||
}))
|
||||
.filter((a: DocusignAccount) => a.accountId.length > 0 && a.baseUri.length > 0);
|
||||
if (accounts.length === 0) {
|
||||
throw new Error('DocuSign userinfo returned no usable accounts');
|
||||
}
|
||||
return {
|
||||
sub: String(data.sub ?? ''),
|
||||
name: String(data.name ?? ''),
|
||||
email: String(data.email ?? ''),
|
||||
accounts,
|
||||
};
|
||||
}
|
||||
|
||||
// defaultAccount picks the account a request should run against: the one flagged
|
||||
// is_default, else the first. Pure — the caller may override by accountId.
|
||||
export function defaultAccount(info: DocusignUserInfo): DocusignAccount {
|
||||
return info.accounts.find((a) => a.isDefault) ?? info.accounts[0];
|
||||
}
|
||||
|
||||
// apiBaseUrl turns an account's base_uri into the eSignature REST root for the
|
||||
// current API version: `${base_uri}/restapi/v2.1`. Every DocuSign API call is
|
||||
// built on top of this. Pure — the request wrappers in docusign-api.ts take
|
||||
// this string.
|
||||
export function apiBaseUrl(baseUri: string): string {
|
||||
return `${baseUri.replace(/\/+$/, '')}/restapi/${ESIGN_API_VERSION}`;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// The envelope-analysis pipeline: given a DocuSign account + envelope + access
|
||||
// token, fetch the combined PDF, extract its text, window it, and run an AI
|
||||
// action over it. This is the shared core the webhook (auto-summarize on
|
||||
// completion) and the panel's server-assisted path both use — one pipeline, no
|
||||
// duplication. It composes the pure modules (docusign-api, pdf-text, hanzo,
|
||||
// actions) and takes its `fetch`, `apiBase`, and pdf `loader` injected, so it is
|
||||
// unit-testable end-to-end without a network or a real PDF parser.
|
||||
|
||||
import { getDocument, getEnvelope, parseEnvelope, type EnvelopeInfo } from './docusign-api.js';
|
||||
import { extractPdfText, type PdfLoader } from './pdf-text.js';
|
||||
import { buildDocumentContext, type AgreementMeta } from './hanzo.js';
|
||||
import { runAction } from './actions.js';
|
||||
import type { AskOptions } from './hanzo.js';
|
||||
|
||||
// The inputs the pipeline needs. `apiBase` is the eSignature REST root for the
|
||||
// account (docusign-oauth.apiBaseUrl), `accessToken` authorizes the DocuSign
|
||||
// reads, and the Hanzo bearer + model ride in `ask`.
|
||||
export interface AnalyzeInput {
|
||||
apiBase: string;
|
||||
accountId: string;
|
||||
envelopeId: string;
|
||||
/** DocuSign OAuth access token — authorizes the envelope/document reads. */
|
||||
accessToken: string;
|
||||
/** The action to run (summarize | extractTerms | riskFlags | draftMemo). */
|
||||
actionId: string;
|
||||
/** Hanzo gateway options (bearer token, model, injected client for tests). */
|
||||
ask?: AskOptions;
|
||||
/** Injected fetch (tests). Defaults to global fetch. */
|
||||
fetch?: typeof fetch;
|
||||
/** Injected pdf.js loader (tests). Defaults to pdf-text's lazy loader. */
|
||||
pdfLoader?: PdfLoader;
|
||||
}
|
||||
|
||||
// The pipeline result: the AI output plus the context that produced it, so the
|
||||
// caller can show the agreement title and how much was analyzed.
|
||||
export interface AnalyzeResult {
|
||||
result: string;
|
||||
envelope: EnvelopeInfo;
|
||||
totalPages: number;
|
||||
includedPages: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
// fetchEnvelopeInfo GETs the envelope metadata. Throws with DocuSign's status on
|
||||
// failure so the caller surfaces the real reason (e.g. 401 expired token).
|
||||
async function fetchEnvelopeInfo(input: AnalyzeInput, doFetch: typeof fetch): Promise<EnvelopeInfo> {
|
||||
const req = getEnvelope(input.apiBase, input.accountId, input.envelopeId, input.accessToken);
|
||||
const resp = await doFetch(req.url, { headers: req.headers });
|
||||
if (!resp.ok) throw new Error(`DocuSign envelope fetch ${resp.status}`);
|
||||
return parseEnvelope(await resp.json());
|
||||
}
|
||||
|
||||
// fetchCombinedPdf GETs the combined PDF (all documents merged) as bytes.
|
||||
async function fetchCombinedPdf(input: AnalyzeInput, doFetch: typeof fetch): Promise<ArrayBuffer> {
|
||||
const req = getDocument(input.apiBase, input.accountId, input.envelopeId, input.accessToken);
|
||||
const resp = await doFetch(req.url, { headers: req.headers });
|
||||
if (!resp.ok) throw new Error(`DocuSign document fetch ${resp.status}`);
|
||||
return resp.arrayBuffer();
|
||||
}
|
||||
|
||||
// analyzeEnvelope runs the whole pipeline: envelope metadata → combined PDF →
|
||||
// text → windowed context → AI action. Pure composition over injected effects;
|
||||
// every step's failure surfaces as a thrown Error the caller logs (the webhook)
|
||||
// or shows (the panel). This is the ONE place the four surfaces converge on a
|
||||
// finished analysis of an agreement.
|
||||
export async function analyzeEnvelope(input: AnalyzeInput): Promise<AnalyzeResult> {
|
||||
const doFetch = input.fetch ?? fetch;
|
||||
|
||||
const envelope = await fetchEnvelopeInfo(input, doFetch);
|
||||
const pdfBytes = await fetchCombinedPdf(input, doFetch);
|
||||
const pages = await extractPdfText(pdfBytes, input.pdfLoader);
|
||||
const ctx = buildDocumentContext(pages);
|
||||
|
||||
const meta: AgreementMeta = {
|
||||
title: envelope.emailSubject,
|
||||
status: envelope.status,
|
||||
envelopeId: envelope.envelopeId || input.envelopeId,
|
||||
};
|
||||
const result = await runAction(input.actionId, ctx, meta, input.ask);
|
||||
|
||||
return {
|
||||
result,
|
||||
envelope,
|
||||
totalPages: ctx.totalPages,
|
||||
includedPages: ctx.includedPages,
|
||||
truncated: ctx.truncated,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// The Hanzo call and its request/response shaping over an AGREEMENT — pure,
|
||||
// host-agnostic, and fully unit-testable (no DocuSign SDK, no DOM). The panel
|
||||
// and the webhook server are the thin glue that read an envelope's document text
|
||||
// and hand it here. Speaks the OpenAI-compatible /v1 wire protocol the whole
|
||||
// Hanzo suite uses (identical to @hanzo/pdf and @hanzo/meetings) so the gateway
|
||||
// sees one shape from every surface.
|
||||
//
|
||||
// This module is the agreement-aware core the headless client wraps: the
|
||||
// document windowing/truncation and prompt assembly a *contract* surface adds on
|
||||
// top of a generic chat call, kept pure so it is tested and reused by both the
|
||||
// panel and the (browser-less) Connect webhook. The four AI actions (their
|
||||
// prompt templates + output shaping) live in actions.ts, layered on `ask`.
|
||||
|
||||
import { createAiClient, extractContent, type AiClient, type ChatMessage } from './client.js';
|
||||
import { DEFAULT_MODEL, DOCUMENT_CHAR_BUDGET, HANZO_API_BASE_URL } from './config.js';
|
||||
|
||||
// SYSTEM_PROMPT grounds every answer in the agreement text. It forbids inventing
|
||||
// terms nobody agreed to (the failure mode that makes a contract assistant
|
||||
// dangerous), asks for clause/section references when the text carries them, and
|
||||
// stays honest about a truncated document. It also states plainly that the
|
||||
// output is not legal advice — a contract tool that pretends otherwise is a
|
||||
// liability.
|
||||
export const SYSTEM_PROMPT =
|
||||
'You are Hanzo AI, an assistant that helps people understand agreements and ' +
|
||||
'contracts inside DocuSign. Work ONLY from the agreement text provided below — ' +
|
||||
'never invent parties, dates, obligations, or clauses that are not supported by ' +
|
||||
'the text. Cite the clause or section number when the text carries it. Be ' +
|
||||
'concise, precise, and neutral. If the document is marked truncated and a ' +
|
||||
'complete answer needs the omitted part, say so plainly rather than guessing. ' +
|
||||
'You provide informational analysis, not legal advice.';
|
||||
|
||||
// ---- Document text windowing / truncation ---------------------------------
|
||||
//
|
||||
// A document arrives as ordered pages (one text block each, from pdf-text). A
|
||||
// long contract won't fit a model window and shouldn't be sent whole. We window
|
||||
// pages IN ORDER from the start and stop at the budget — order carries meaning
|
||||
// in a contract (recitals, then terms, then signatures), so we never reorder,
|
||||
// and we always include at least the first page.
|
||||
|
||||
// One page's extracted text.
|
||||
export interface PageTextItem {
|
||||
page: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
// The result of windowing a document down to what we'll attach: the rendered
|
||||
// text, how many pages it covers, and whether anything was dropped (so the
|
||||
// prompt and UI can say so honestly).
|
||||
export interface DocumentContext {
|
||||
text: string;
|
||||
totalPages: number;
|
||||
includedPages: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
// renderPage turns one page into a labeled block. Pure so the exact rendering
|
||||
// (used to measure the budget) is what we also send.
|
||||
export function renderPage(item: PageTextItem): string {
|
||||
const body = item.text.trim();
|
||||
return body ? `[Page ${item.page}]\n${body}` : `[Page ${item.page}]`;
|
||||
}
|
||||
|
||||
// buildDocumentContext caps the rendered document at `budget` characters. It
|
||||
// walks pages IN ORDER and stops when adding the next page would exceed the
|
||||
// budget — always including at least the first page (hard-cut to the budget if
|
||||
// that one page alone is over). `truncated` is true whenever not every page made
|
||||
// it in. Pure and total: deterministic, no I/O. (Same windowing contract as
|
||||
// @hanzo/meetings' transcript windowing — one algorithm for "fit ordered text
|
||||
// to a budget".)
|
||||
export function buildDocumentContext(
|
||||
pages: PageTextItem[],
|
||||
budget: number = DOCUMENT_CHAR_BUDGET,
|
||||
): DocumentContext {
|
||||
const total = pages.length;
|
||||
if (total === 0) {
|
||||
return { text: '', totalPages: 0, includedPages: 0, truncated: false };
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
let used = 0;
|
||||
let hardCut = false;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const block = renderPage(pages[i]);
|
||||
const addition = (blocks.length === 0 ? '' : '\n\n') + block;
|
||||
if (used + addition.length > budget) {
|
||||
if (blocks.length === 0) {
|
||||
blocks.push(block.slice(0, budget));
|
||||
used = budget;
|
||||
hardCut = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
blocks.push(addition);
|
||||
used += addition.length;
|
||||
}
|
||||
|
||||
const includedPages = hardCut ? 1 : blocks.length;
|
||||
const truncated = includedPages < total || hardCut;
|
||||
return { text: blocks.join(''), totalPages: total, includedPages, truncated };
|
||||
}
|
||||
|
||||
// contextNote is the one honest sentence prepended to the agreement so the model
|
||||
// (and, echoed in the panel, the user) knows the scope of what it sees. Never
|
||||
// claim the whole agreement was sent when it wasn't.
|
||||
export function contextNote(ctx: DocumentContext): string {
|
||||
if (ctx.totalPages === 0) return 'No agreement text was available.';
|
||||
return ctx.truncated
|
||||
? `Agreement text: ${ctx.includedPages} of ${ctx.totalPages} pages (truncated to fit — answer only from what is shown and say so if the omitted part is needed).`
|
||||
: `Agreement text: the full document (${ctx.totalPages} page${ctx.totalPages === 1 ? '' : 's'}).`;
|
||||
}
|
||||
|
||||
// ---- Prompt assembly ------------------------------------------------------
|
||||
|
||||
// Optional structured context about the agreement the DocuSign API supplies
|
||||
// (title, status). Attached as a short header above the document so answers can
|
||||
// reference the agreement without the model guessing.
|
||||
export interface AgreementMeta {
|
||||
title?: string;
|
||||
status?: string;
|
||||
envelopeId?: string;
|
||||
}
|
||||
|
||||
function agreementHeader(meta: AgreementMeta | undefined): string {
|
||||
if (!meta) return '';
|
||||
const parts: string[] = [];
|
||||
if (meta.title) parts.push(`Agreement: ${meta.title}`);
|
||||
if (meta.status) parts.push(`Status: ${meta.status}`);
|
||||
return parts.length > 0 ? parts.join('\n') + '\n\n' : '';
|
||||
}
|
||||
|
||||
// buildMessages turns a task (the user's instruction, or one of the action
|
||||
// prompts) plus the windowed document + optional metadata into the
|
||||
// OpenAI-compatible message list. The agreement is fenced so the model treats it
|
||||
// as data, not instructions, and the honest context note rides inside the user
|
||||
// turn so it is never lost.
|
||||
export function buildMessages(
|
||||
task: string,
|
||||
ctx: DocumentContext,
|
||||
meta?: AgreementMeta,
|
||||
): ChatMessage[] {
|
||||
const system: ChatMessage = { role: 'system', content: SYSTEM_PROMPT };
|
||||
const header = agreementHeader(meta);
|
||||
const user: ChatMessage = {
|
||||
role: 'user',
|
||||
content:
|
||||
`${header}${contextNote(ctx)}\n\n` +
|
||||
`---- agreement ----\n${ctx.text}\n---- end agreement ----\n\n` +
|
||||
`---- task ----\n${task}`,
|
||||
};
|
||||
return [system, user];
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
}
|
||||
|
||||
// ask runs ONE non-streaming completion over an agreement and returns the
|
||||
// answer. This is the single path from every DocuSign surface to the model —
|
||||
// the four actions, a freeform question, and the webhook auto-summary all funnel
|
||||
// here. token may be empty (the gateway serves anonymous/limited models).
|
||||
export async function ask(
|
||||
task: string,
|
||||
ctx: DocumentContext,
|
||||
meta: AgreementMeta | undefined,
|
||||
opts: AskOptions = {},
|
||||
): Promise<string> {
|
||||
const client =
|
||||
opts.client ??
|
||||
createAiClient({ token: opts.token, baseURL: opts.baseURL ?? HANZO_API_BASE_URL });
|
||||
const res = await client.chat.completions.create({
|
||||
model: opts.model ?? DEFAULT_MODEL,
|
||||
messages: buildMessages(task, ctx, meta),
|
||||
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 client =
|
||||
opts.client ??
|
||||
createAiClient({ token: opts.token, baseURL: opts.baseURL ?? HANZO_API_BASE_URL });
|
||||
const models = await client.models.list(opts.signal);
|
||||
return models.map((m) => m.id);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hanzo AI for DocuSign</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" id="agreement-title">Agreement</span>
|
||||
</header>
|
||||
|
||||
<!-- One-click actions over the agreement. Chips are built from the catalog. -->
|
||||
<div class="chips" id="chips"></div>
|
||||
|
||||
<label for="doc">Agreement text</label>
|
||||
<textarea id="doc" placeholder="Paste the agreement text here, or open this panel from DocuSign with an envelope in context."></textarea>
|
||||
|
||||
<label for="prompt">Ask about this agreement</label>
|
||||
<textarea id="prompt" placeholder="e.g. What is the notice period for termination? · Is there an auto-renewal clause? · Which party indemnifies the other?"></textarea>
|
||||
|
||||
<div class="row">
|
||||
<button id="run">Ask Hanzo</button>
|
||||
<button id="stop" class="secondary" disabled>Stop</button>
|
||||
<span class="spacer"></span>
|
||||
</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." readonly></textarea>
|
||||
|
||||
<footer>Routed through api.hanzo.ai · analysis is grounded in the agreement text · informational, not legal advice.</footer>
|
||||
|
||||
<script type="module" src="__ENTRY__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
// Public surface of @hanzo/docusign: 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 agreement-analysis pipeline in
|
||||
// their own service imports from here.
|
||||
|
||||
export * from './config.js';
|
||||
export * from './client.js';
|
||||
export * from './docusign-oauth.js';
|
||||
export * from './docusign-api.js';
|
||||
export * from './webhook.js';
|
||||
export * from './pdf-text.js';
|
||||
export * from './hanzo.js';
|
||||
export * from './actions.js';
|
||||
export * from './envelope.js';
|
||||
export * from './panel.js';
|
||||
@@ -0,0 +1,43 @@
|
||||
// Pure panel logic: parsing the DocuSign launch context and turning pasted
|
||||
// agreement text into pages. Split out of app.ts (the DOM glue) so it is
|
||||
// unit-testable without a browser — app.ts imports these and only adds the
|
||||
// impure DOM wiring.
|
||||
|
||||
import type { PageTextItem } from './hanzo.js';
|
||||
|
||||
// The envelope context DocuSign passes when it launches the panel. All optional:
|
||||
// the panel still runs as a paste-in tool when opened outside DocuSign.
|
||||
export interface LaunchContext {
|
||||
envelopeId: string;
|
||||
accountId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
// parseLaunchContext reads the launch URL's query into a LaunchContext. DocuSign
|
||||
// custom actions / linked apps append the envelope context to the configured
|
||||
// URL; we read the documented names (and their snake_case variants) and tolerate
|
||||
// their absence. Pure — takes a query string so it is unit-tested without a DOM.
|
||||
export function parseLaunchContext(search: string): LaunchContext {
|
||||
const q = new URLSearchParams(search);
|
||||
return {
|
||||
envelopeId: (q.get('envelopeId') || q.get('envelope_id') || '').trim(),
|
||||
accountId: (q.get('accountId') || q.get('account_id') || '').trim(),
|
||||
title: (q.get('title') || q.get('emailSubject') || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// asPages turns the pasted/injected agreement text into the PageTextItem[] the
|
||||
// windowing expects. When the panel is handed already-extracted text (not a PDF)
|
||||
// there is one logical "page"; the windowing still applies the char budget. Pure.
|
||||
export function asPages(text: string): PageTextItem[] {
|
||||
const t = text.trim();
|
||||
return t ? [{ page: 1, text: t }] : [];
|
||||
}
|
||||
|
||||
// panelTitle chooses the header label for the agreement: its subject, else the
|
||||
// envelope id, else a neutral default. Pure — the DOM glue just assigns it.
|
||||
export function panelTitle(ctx: LaunchContext): string {
|
||||
if (ctx.title) return ctx.title;
|
||||
if (ctx.envelopeId) return `Envelope ${ctx.envelopeId}`;
|
||||
return 'Agreement';
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// PDF text extraction for an envelope's documents. DocuSign returns agreement
|
||||
// documents as PDF bytes (getDocument); the AI actions run over their text. The
|
||||
// pure text-assembly helper (itemsToText) is isolated from pdf.js and the DOM so
|
||||
// it is unit-testable; extractPdfText is the thin wrapper that drives pdf.js over
|
||||
// bytes. Both the server (webhook) and the browser panel use this module — one
|
||||
// extraction path, no duplication.
|
||||
//
|
||||
// pdfjs-dist runs in both Node and the browser. In Node it needs no DOM canvas
|
||||
// for TEXT extraction (getTextContent is text-only), so the same call works
|
||||
// server-side. The panel bundle points GlobalWorkerOptions at the emitted
|
||||
// worker (build.js copies pdf.worker.mjs); the server runs pdf.js on the main
|
||||
// thread via the legacy build, which needs no worker.
|
||||
|
||||
import type { PageTextItem } from './hanzo.js';
|
||||
|
||||
// A pdf.js text item is either a real text run (has `str`) or a marked-content
|
||||
// marker (no `str`). We only want the runs. Kept as a narrow structural type so
|
||||
// itemsToText is testable with plain objects — no pdf.js import needed to test.
|
||||
export interface TextRun {
|
||||
str?: string;
|
||||
hasEOL?: boolean;
|
||||
}
|
||||
|
||||
// itemsToText flattens one page's text items into a single string. pdf.js yields
|
||||
// positioned runs with a `hasEOL` flag at line ends; we join runs with a space
|
||||
// and break the line on hasEOL, then collapse the runs of spaces a PDF's
|
||||
// glyph-by-glyph layout tends to produce. Pure and total. (Identical algorithm
|
||||
// to @hanzo/pdf — one way to turn PDF runs into text.)
|
||||
export function itemsToText(items: TextRun[]): string {
|
||||
let out = '';
|
||||
for (const it of items) {
|
||||
if (typeof it.str !== 'string') continue; // marked-content marker — skip
|
||||
out += it.str;
|
||||
if (it.hasEOL) out += '\n';
|
||||
else if (it.str && !it.str.endsWith(' ')) out += ' ';
|
||||
}
|
||||
return out
|
||||
.replace(/[ \t]{2,}/g, ' ')
|
||||
.replace(/ +\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// extractPdfText loads a PDF from bytes and returns one PageTextItem per page,
|
||||
// in order. `loadDocument` is injected so tests drive it with a fake pdf.js
|
||||
// document (no real parser needed) and so the server and browser can each pass
|
||||
// their own configured getDocument. The default resolves pdfjs-dist lazily —
|
||||
// only a real call pulls the (large) parser in.
|
||||
export async function extractPdfText(
|
||||
data: ArrayBuffer,
|
||||
loadDocument: PdfLoader = defaultLoader,
|
||||
): Promise<PageTextItem[]> {
|
||||
const doc = await loadDocument(data);
|
||||
const pages: PageTextItem[] = [];
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
const page = await doc.getPage(n);
|
||||
const content = await page.getTextContent();
|
||||
pages.push({ page: n, text: itemsToText(content.items as TextRun[]) });
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
// The minimal pdf.js surface extractPdfText drives. A test supplies a stub with
|
||||
// this shape; the real loader adapts pdfjs-dist to it.
|
||||
export interface PdfPage {
|
||||
getTextContent(): Promise<{ items: TextRun[] }>;
|
||||
}
|
||||
export interface PdfDoc {
|
||||
numPages: number;
|
||||
getPage(n: number): Promise<PdfPage>;
|
||||
}
|
||||
export type PdfLoader = (data: ArrayBuffer) => Promise<PdfDoc>;
|
||||
|
||||
// defaultLoader lazily imports pdfjs-dist and opens the document. pdf.js takes
|
||||
// ownership of the Uint8Array it's handed (it detaches the buffer), so we copy
|
||||
// first. Kept out of the pure surface so the bundle only pulls pdf.js when text
|
||||
// is actually extracted.
|
||||
const defaultLoader: PdfLoader = async (data: ArrayBuffer): Promise<PdfDoc> => {
|
||||
const pdfjs: any = await import('pdfjs-dist');
|
||||
const bytes = new Uint8Array(data.slice(0));
|
||||
const task = pdfjs.getDocument({ data: bytes });
|
||||
const doc = await task.promise;
|
||||
return {
|
||||
numPages: doc.numPages,
|
||||
getPage: (n: number) => doc.getPage(n),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
// The DocuSign install + Connect webhook service. This is the ONLY place the
|
||||
// DocuSign secret key and the Connect HMAC key exist — both read from the
|
||||
// environment (never bundled, never sent to the browser). It:
|
||||
//
|
||||
// GET /oauth/install → redirect the user to DocuSign's OAuth consent
|
||||
// GET /oauth/callback → exchange the code for a token, discover the account
|
||||
// base_uri via /oauth/userinfo, and store the token
|
||||
// POST /connect → verify the DocuSign Connect HMAC signature, then on
|
||||
// an envelope reaching "completed" fetch the combined
|
||||
// PDF, extract text, and summarize it via @hanzo/ai
|
||||
// GET /healthz → readiness
|
||||
//
|
||||
// It is a dependency-free Node http handler built on the pure modules (config,
|
||||
// docusign-oauth, docusign-api, webhook, envelope, hanzo) so it is deployable
|
||||
// behind hanzoai/ingress as a small service at docusign.hanzo.ai. The pure logic
|
||||
// it wraps is what the tests cover; the http server is an integration concern.
|
||||
//
|
||||
// node dist/server.js (after build.js bundles it)
|
||||
//
|
||||
// Required environment (see config.readServerConfig):
|
||||
// DOCUSIGN_CLIENT_ID, DOCUSIGN_CLIENT_SECRET, DOCUSIGN_REDIRECT_URI,
|
||||
// DOCUSIGN_CONNECT_HMAC_KEY
|
||||
// DOCUSIGN_ENV (demo | production, default demo)
|
||||
// HANZO_API_KEY (optional — needed to actually summarize; without it the
|
||||
// webhook verifies + routes but skips summarization)
|
||||
// PORT (default 8789)
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { OAUTH_SCOPES, readServerConfig, type ServerConfig } from './config.js';
|
||||
import {
|
||||
authorizeUrl,
|
||||
tokenExchange,
|
||||
parseTokenResponse,
|
||||
userInfoUrl,
|
||||
parseUserInfo,
|
||||
defaultAccount,
|
||||
apiBaseUrl,
|
||||
} from './docusign-oauth.js';
|
||||
import {
|
||||
signatureHeaders,
|
||||
verifySignature,
|
||||
routeEvent,
|
||||
type ConnectAction,
|
||||
} from './webhook.js';
|
||||
import { analyzeEnvelope } from './envelope.js';
|
||||
|
||||
// A stored account authorization: the token to call the eSignature API and the
|
||||
// REST base for that account. In-memory here keyed by accountId; a production
|
||||
// deployment persists this to KMS/Valkey (keyed by accountId) so the webhook can
|
||||
// act on any authorized account after a restart. The install flow populates it;
|
||||
// the webhook reads it.
|
||||
interface AccountAuth {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
apiBase: string;
|
||||
}
|
||||
const accounts = new Map<string, AccountAuth>();
|
||||
|
||||
// readRawBody collects the raw request bytes as a utf8 string. The Connect HMAC
|
||||
// signature is computed over these RAW bytes, so we must NOT JSON.parse-and-
|
||||
// reserialize before verifying (key order/whitespace would differ and every
|
||||
// event would fail). One read, used for both verification and parsing.
|
||||
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');
|
||||
}
|
||||
|
||||
function json(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function log(fields: Record<string, unknown>): void {
|
||||
console.log(JSON.stringify(fields));
|
||||
}
|
||||
|
||||
// GET /oauth/install → 302 to DocuSign's consent screen. `state` is a fresh CSRF
|
||||
// token; a production deployment persists it (KMS/Valkey) to re-check on
|
||||
// callback. Here we mint one and carry it — the checkable value is what matters.
|
||||
function handleInstall(cfg: ServerConfig, res: ServerResponse): void {
|
||||
const state = randomUUID();
|
||||
const url = authorizeUrl({
|
||||
env: cfg.docusignEnv,
|
||||
clientId: cfg.docusignClientId,
|
||||
redirectUri: cfg.docusignRedirectUri,
|
||||
scopes: OAUTH_SCOPES,
|
||||
state,
|
||||
});
|
||||
res.writeHead(302, { Location: url });
|
||||
res.end();
|
||||
}
|
||||
|
||||
// GET /oauth/callback?code=…&state=… → exchange the code for a token (secret in
|
||||
// the Basic header, server-side), then call /oauth/userinfo to discover the
|
||||
// account's REST base_uri, and store the authorization for the webhook to use.
|
||||
async function handleOAuthCallback(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) return json(res, 400, { error: 'missing code' });
|
||||
|
||||
const exReq = tokenExchange({
|
||||
env: cfg.docusignEnv,
|
||||
clientId: cfg.docusignClientId,
|
||||
clientSecret: cfg.docusignClientSecret,
|
||||
code,
|
||||
});
|
||||
let tokenSet;
|
||||
try {
|
||||
const exResp = await fetch(exReq.url, { method: 'POST', headers: exReq.headers, body: exReq.body });
|
||||
tokenSet = parseTokenResponse(await exResp.json().catch(() => ({})));
|
||||
} catch (e: any) {
|
||||
return json(res, 400, { error: e?.message || 'token exchange failed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const uiResp = await fetch(userInfoUrl(cfg.docusignEnv), {
|
||||
headers: { Authorization: `Bearer ${tokenSet.access_token}` },
|
||||
});
|
||||
const info = parseUserInfo(await uiResp.json().catch(() => ({})));
|
||||
const acct = defaultAccount(info);
|
||||
accounts.set(acct.accountId, {
|
||||
accessToken: tokenSet.access_token,
|
||||
refreshToken: tokenSet.refresh_token,
|
||||
apiBase: apiBaseUrl(acct.baseUri),
|
||||
});
|
||||
log({ msg: 'oauth: account authorized', accountId: acct.accountId, accountName: acct.accountName });
|
||||
return json(res, 200, { ok: true, installed: true, accountId: acct.accountId, accountName: acct.accountName });
|
||||
} catch (e: any) {
|
||||
return json(res, 400, { error: e?.message || 'userinfo failed' });
|
||||
}
|
||||
}
|
||||
|
||||
// handleEnvelopeCompleted is the auto-summary pipeline: for a completed envelope,
|
||||
// look up the account's stored token and run analyzeEnvelope (fetch combined PDF
|
||||
// → extract text → summarize). It NEVER throws into the webhook handler (the
|
||||
// webhook must 200 fast so DocuSign does not retry); it logs the outcome and
|
||||
// returns the summary (or '') for tests/callers that await it.
|
||||
async function handleEnvelopeCompleted(
|
||||
cfg: ServerConfig,
|
||||
action: Extract<ConnectAction, { kind: 'envelope_completed' }>,
|
||||
): Promise<string> {
|
||||
if (!cfg.hanzoApiKey) {
|
||||
log({ msg: 'envelope.completed: HANZO_API_KEY unset, skipping summary', envelopeId: action.envelopeId });
|
||||
return '';
|
||||
}
|
||||
const auth = accounts.get(action.accountId);
|
||||
if (!auth) {
|
||||
log({ msg: 'envelope.completed: no stored authorization for account, skipping', accountId: action.accountId });
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const out = await analyzeEnvelope({
|
||||
apiBase: auth.apiBase,
|
||||
accountId: action.accountId,
|
||||
envelopeId: action.envelopeId,
|
||||
accessToken: auth.accessToken,
|
||||
actionId: 'summarize',
|
||||
ask: { token: cfg.hanzoApiKey },
|
||||
});
|
||||
log({
|
||||
msg: 'envelope.completed: summary generated',
|
||||
envelopeId: action.envelopeId,
|
||||
pages: out.includedPages,
|
||||
totalPages: out.totalPages,
|
||||
truncated: out.truncated,
|
||||
summaryChars: out.result.length,
|
||||
});
|
||||
// Deliver hook: a production deployment stores `out.result` (hanzoai/docdb),
|
||||
// posts it back to DocuSign as an envelope custom field, or notifies the
|
||||
// sender here. The pipeline through summary is complete; the delivery target
|
||||
// is a per-deployment choice (documented in the README).
|
||||
return out.result;
|
||||
} catch (e: any) {
|
||||
log({ msg: 'envelope.completed: summary failed', envelopeId: action.envelopeId, error: e?.message });
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// POST /connect → verify, then dispatch. Verification is the trust gate: an event
|
||||
// with a bad/absent signature is a 401 before any document fetch. DocuSign
|
||||
// Connect sends one X-DocuSign-Signature-N header per configured HMAC key; we
|
||||
// accept a message signed with OUR key.
|
||||
async function handleWebhook(cfg: ServerConfig, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const raw = await readRawBody(req);
|
||||
const sigs = signatureHeaders(req.headers as Record<string, unknown>);
|
||||
|
||||
if (!verifySignature(cfg.connectHmacKey, sigs, raw)) {
|
||||
log({ msg: 'connect: signature verification failed' });
|
||||
return json(res, 401, { error: 'invalid signature' });
|
||||
}
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = JSON.parse(raw);
|
||||
} catch {
|
||||
return json(res, 400, { error: 'invalid json' });
|
||||
}
|
||||
|
||||
const action = routeEvent(body);
|
||||
switch (action.kind) {
|
||||
case 'envelope_completed':
|
||||
// Acknowledge DocuSign immediately; run the summary pipeline in the
|
||||
// background so the webhook returns fast (Connect retries slow endpoints).
|
||||
json(res, 200, { ok: true });
|
||||
void handleEnvelopeCompleted(cfg, action);
|
||||
return;
|
||||
|
||||
case 'ignored':
|
||||
return json(res, 200, { ok: true, ignored: action.status || action.event });
|
||||
}
|
||||
}
|
||||
|
||||
// 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 === 'POST' && url.pathname === '/connect') return await handleWebhook(cfg, req, res);
|
||||
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 docusign service up', port: cfg.port, env: cfg.docusignEnv, summarize: !!cfg.hanzoApiKey });
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
: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; max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
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#doc { min-height: 120px; resize: vertical; }
|
||||
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; }
|
||||
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; }
|
||||
.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: 4px; }
|
||||
.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,143 @@
|
||||
// DocuSign Connect webhook: HMAC signature verification and event routing — all
|
||||
// pure over their inputs so they are unit-testable without an http server.
|
||||
// server.ts is the thin http glue that reads the raw body + headers and calls
|
||||
// these. Crypto is Node's stdlib (node:crypto), never a dependency.
|
||||
//
|
||||
// DocuSign Connect signs every message when HMAC security is enabled. For each
|
||||
// configured HMAC key it adds a header:
|
||||
// X-DocuSign-Signature-1: base64(HMAC_SHA256(hmacKey, rawBody))
|
||||
// X-DocuSign-Signature-2: base64(HMAC_SHA256(hmacKey2, rawBody)) (2nd key)
|
||||
// We recompute the digest over the RAW body bytes (not a re-serialized object —
|
||||
// key order and whitespace would differ) and compare in constant time against
|
||||
// EACH provided signature header. A message matching any configured key is
|
||||
// authentic. This is the whole trust boundary: an event that fails here is
|
||||
// discarded before any document is fetched.
|
||||
// Docs: developers.docusign.com/platform/webhooks/connect/hmac.
|
||||
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
// computeSignature is the one crypto primitive: base64(HMAC-SHA256(key, body)).
|
||||
// Both verification and any test fixture are built from it, so the value we
|
||||
// compare is produced exactly as DocuSign produces it.
|
||||
export function computeSignature(hmacKey: string, rawBody: string): string {
|
||||
return createHmac('sha256', hmacKey).update(rawBody, 'utf8').digest('base64');
|
||||
}
|
||||
|
||||
// constantTimeEqual compares two strings without leaking length/position via
|
||||
// timing. Different-length inputs are unequal (and short-circuit safely — we
|
||||
// never feed mismatched buffers to timingSafeEqual, which would throw).
|
||||
export function constantTimeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
// signatureHeaders collects the X-DocuSign-Signature-N values out of a header
|
||||
// map, in order (1, 2, 3, …). Node lowercases header names; DocuSign may send
|
||||
// one or several depending on how many HMAC keys are configured. Pure so the
|
||||
// header-plucking is testable. Values that aren't strings are skipped.
|
||||
export function signatureHeaders(headers: Record<string, unknown>): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 1; ; i++) {
|
||||
const v = headers[`x-docusign-signature-${i}`];
|
||||
if (typeof v !== 'string' || v.length === 0) break;
|
||||
out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// verifySignature is the trust gate: recompute base64(HMAC-SHA256(key, rawBody))
|
||||
// and compare in constant time against EVERY provided X-DocuSign-Signature-*
|
||||
// header. A match on any header means the message is authentic (DocuSign
|
||||
// includes one signature per configured key; a listener that knows one key
|
||||
// accepts messages signed with it). Returns false on any missing input rather
|
||||
// than throwing, so a malformed request is a clean reject. `signatures` is the
|
||||
// list from signatureHeaders(); pass a single key here (the one this listener
|
||||
// was configured with).
|
||||
export function verifySignature(
|
||||
hmacKey: string,
|
||||
signatures: string[],
|
||||
rawBody: string,
|
||||
): boolean {
|
||||
if (!hmacKey || signatures.length === 0) return false;
|
||||
const expected = computeSignature(hmacKey, rawBody);
|
||||
for (const sig of signatures) {
|
||||
if (constantTimeEqual(sig, expected)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- Event routing --------------------------------------------------------
|
||||
//
|
||||
// After a request is verified, we parse the DocuSign Connect message into a
|
||||
// discriminated action so the server can dispatch. Connect's "aggregate" JSON
|
||||
// payload nests the envelope under data.envelopeSummary (or data.envelopeId for
|
||||
// the id). We act on the one event that produces a summary — an envelope reaching
|
||||
// "completed", which has downloadable, fully-executed documents — and treat
|
||||
// other statuses as signals to acknowledge and ignore. Everything unknown is a
|
||||
// clean 200 (acknowledge, do nothing) rather than an error.
|
||||
|
||||
export type ConnectAction =
|
||||
| { kind: 'envelope_completed'; accountId: string; envelopeId: string; emailSubject: string; status: string }
|
||||
| { kind: 'ignored'; event: string; status: string };
|
||||
|
||||
// eventStatus extracts the envelope status from a Connect message, tolerating
|
||||
// the two shapes Connect emits: the modern JSON "aggregate" payload
|
||||
// (data.envelopeSummary.status) and the flatter event payload (status /
|
||||
// data.status). Lowercased for a stable compare. Pure.
|
||||
export function eventStatus(body: any): string {
|
||||
const s =
|
||||
body?.data?.envelopeSummary?.status ??
|
||||
body?.data?.status ??
|
||||
body?.status ??
|
||||
'';
|
||||
return String(s).toLowerCase();
|
||||
}
|
||||
|
||||
// accountId / envelopeId pull the identifiers out of a Connect message. The
|
||||
// aggregate payload carries accountId at the top level and envelopeId under
|
||||
// data; older shapes nest them differently, so we check the documented
|
||||
// locations in order. Pure.
|
||||
export function messageAccountId(body: any): string {
|
||||
return String(body?.data?.accountId ?? body?.accountId ?? '');
|
||||
}
|
||||
|
||||
export function messageEnvelopeId(body: any): string {
|
||||
return String(
|
||||
body?.data?.envelopeId ??
|
||||
body?.data?.envelopeSummary?.envelopeId ??
|
||||
body?.envelopeId ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
// emailSubject pulls the envelope subject (title) for the summary header. Pure.
|
||||
function messageEmailSubject(body: any): string {
|
||||
return String(
|
||||
body?.data?.envelopeSummary?.emailSubject ??
|
||||
body?.data?.emailSubject ??
|
||||
body?.emailSubject ??
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
// routeEvent turns a PARSED, ALREADY-VERIFIED Connect message into a
|
||||
// ConnectAction. Verification is the caller's job (verifySignature) — this is
|
||||
// pure dispatch over the parsed JSON, so it is unit-testable with plain objects.
|
||||
// Only a completed envelope triggers work; every other status is acknowledged
|
||||
// and ignored.
|
||||
export function routeEvent(body: any): ConnectAction {
|
||||
const event = String(body?.event ?? '');
|
||||
const status = eventStatus(body);
|
||||
if (status === 'completed') {
|
||||
return {
|
||||
kind: 'envelope_completed',
|
||||
accountId: messageAccountId(body),
|
||||
envelopeId: messageEnvelopeId(body),
|
||||
emailSubject: messageEmailSubject(body),
|
||||
status,
|
||||
};
|
||||
}
|
||||
return { kind: 'ignored', event, status };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ACTIONS, isActionId, actionPrompt, actionList, runAction } from '../src/actions';
|
||||
import { buildDocumentContext } from '../src/hanzo';
|
||||
import type { AiClient } from '../src/client';
|
||||
|
||||
describe('the four actions', () => {
|
||||
it('are exactly summarize, extractTerms, riskFlags, draftMemo', () => {
|
||||
expect(Object.keys(ACTIONS).sort()).toEqual(['draftMemo', 'extractTerms', 'riskFlags', 'summarize']);
|
||||
});
|
||||
it('each has a non-trivial prompt and a label', () => {
|
||||
for (const [, a] of Object.entries(ACTIONS)) {
|
||||
expect(a.prompt.length).toBeGreaterThan(40);
|
||||
expect(a.label.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
it('extractTerms asks for parties, dates, payment, obligations, governing law', () => {
|
||||
const p = ACTIONS.extractTerms.prompt.toLowerCase();
|
||||
for (const term of ['parties', 'date', 'payment', 'obligation', 'governing law']) {
|
||||
expect(p).toContain(term);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActionId / actionPrompt / actionList', () => {
|
||||
it('narrows known ids only', () => {
|
||||
expect(isActionId('summarize')).toBe(true);
|
||||
expect(isActionId('nope')).toBe(false);
|
||||
});
|
||||
it('resolves a prompt and throws on an unknown id', () => {
|
||||
expect(actionPrompt('riskFlags')).toBe(ACTIONS.riskFlags.prompt);
|
||||
expect(() => actionPrompt('bogus')).toThrow(/Unknown action/);
|
||||
});
|
||||
it('lists id+label in catalog order', () => {
|
||||
expect(actionList().map((a) => a.id)).toEqual(['summarize', 'extractTerms', 'riskFlags', 'draftMemo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAction (single path to the model)', () => {
|
||||
it('sends the action prompt as the task over the windowed agreement', async () => {
|
||||
let sentTask = '';
|
||||
const client: AiClient = {
|
||||
chat: {
|
||||
completions: {
|
||||
async create(params) {
|
||||
sentTask = (params.messages[1] as { content: string }).content;
|
||||
return { choices: [{ message: { role: 'assistant', content: 'RESULT' } }] };
|
||||
},
|
||||
},
|
||||
},
|
||||
models: { async list() { return []; } },
|
||||
};
|
||||
const ctx = buildDocumentContext([{ page: 1, text: 'The parties agree to pay $100.' }]);
|
||||
const out = await runAction('summarize', ctx, { title: 'MSA' }, { client });
|
||||
expect(out).toBe('RESULT');
|
||||
expect(sentTask).toContain(ACTIONS.summarize.prompt);
|
||||
expect(sentTask).toContain('The parties agree to pay $100.');
|
||||
});
|
||||
|
||||
it('throws on an unknown action before any call', async () => {
|
||||
await expect(runAction('bogus', buildDocumentContext([]), undefined, {})).rejects.toThrow(/Unknown action/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
chatRequestBody,
|
||||
extractContent,
|
||||
parseModels,
|
||||
createAiClient,
|
||||
} from '../src/client';
|
||||
|
||||
describe('chatRequestBody', () => {
|
||||
it('always sets stream:false and includes model+messages', () => {
|
||||
const body = chatRequestBody({ model: 'zen5', messages: [{ role: 'user', content: 'hi' }] });
|
||||
expect(body).toMatchObject({ model: 'zen5', stream: false });
|
||||
expect(body.messages).toHaveLength(1);
|
||||
expect('temperature' in body).toBe(false);
|
||||
expect('max_tokens' in body).toBe(false);
|
||||
});
|
||||
it('includes temperature/max_tokens only when provided', () => {
|
||||
const body = chatRequestBody({ model: 'zen5', messages: [], temperature: 0.2, max_tokens: 500 });
|
||||
expect(body.temperature).toBe(0.2);
|
||||
expect(body.max_tokens).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractContent', () => {
|
||||
it('reads choices[].message.content', () => {
|
||||
expect(extractContent({ choices: [{ message: { role: 'assistant', content: 'hello' } }] })).toBe('hello');
|
||||
});
|
||||
it('falls back to choices[].text and top-level content', () => {
|
||||
expect(extractContent({ choices: [{ text: 'legacy' }] })).toBe('legacy');
|
||||
expect(extractContent({ content: 'top' })).toBe('top');
|
||||
});
|
||||
it('throws the gateway error message', () => {
|
||||
expect(() => extractContent({ error: { message: 'rate limited' } })).toThrow(/rate limited/);
|
||||
expect(() => extractContent({ error: 'bad key' })).toThrow(/bad key/);
|
||||
});
|
||||
it('throws on empty content', () => {
|
||||
expect(() => extractContent({ choices: [{ message: { role: 'assistant', content: '' } }] })).toThrow(/no content/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseModels', () => {
|
||||
it('reads data[] of objects, models[], or a bare array of strings', () => {
|
||||
expect(parseModels({ data: [{ id: 'zen5' }, { id: 'zen-coder' }] }).map((m) => m.id)).toEqual(['zen5', 'zen-coder']);
|
||||
expect(parseModels({ models: [{ id: 'a' }] }).map((m) => m.id)).toEqual(['a']);
|
||||
expect(parseModels(['x', 'y']).map((m) => m.id)).toEqual(['x', 'y']);
|
||||
});
|
||||
it('drops entries without an id', () => {
|
||||
expect(parseModels({ data: [{ id: '' }, { foo: 1 }, { id: 'ok' }] }).map((m) => m.id)).toEqual(['ok']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAiClient (injected fetch — no network)', () => {
|
||||
it('POSTs /v1/chat/completions with the bearer and returns the completion', async () => {
|
||||
let seen: { url: string; init: RequestInit } | null = null;
|
||||
const fakeFetch = (async (url: any, init: any) => {
|
||||
seen = { url: String(url), init };
|
||||
return new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }), { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const ai = createAiClient({ token: 'hk-1', baseURL: 'https://api.hanzo.ai', fetch: fakeFetch });
|
||||
const res = await ai.chat.completions.create({ model: 'zen5', messages: [{ role: 'user', content: 'hi' }] });
|
||||
expect(extractContent(res)).toBe('ok');
|
||||
expect(seen!.url).toBe('https://api.hanzo.ai/v1/chat/completions');
|
||||
expect((seen!.init.headers as any).Authorization).toBe('Bearer hk-1');
|
||||
const sent = JSON.parse(seen!.init.body as string);
|
||||
expect(sent).toMatchObject({ model: 'zen5', stream: false });
|
||||
});
|
||||
|
||||
it('omits Authorization when the token is empty (anonymous)', async () => {
|
||||
let headers: any;
|
||||
const fakeFetch = (async (_url: any, init: any) => {
|
||||
headers = init.headers;
|
||||
return new Response(JSON.stringify({ choices: [{ message: { content: 'x' } }] }), { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
const ai = createAiClient({ fetch: fakeFetch });
|
||||
await ai.chat.completions.create({ model: 'zen5', messages: [] });
|
||||
expect('Authorization' in headers).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces a non-2xx gateway error', async () => {
|
||||
const fakeFetch = (async () => new Response(JSON.stringify({ error: { message: 'nope' } }), { status: 401 })) as unknown as typeof fetch;
|
||||
const ai = createAiClient({ fetch: fakeFetch });
|
||||
await expect(ai.chat.completions.create({ model: 'zen5', messages: [] })).rejects.toThrow(/nope/);
|
||||
});
|
||||
|
||||
it('lists models from /v1/models', async () => {
|
||||
const fakeFetch = (async (url: any) => {
|
||||
expect(String(url)).toBe('https://api.hanzo.ai/v1/models');
|
||||
return new Response(JSON.stringify({ data: [{ id: 'zen5' }] }), { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
const ai = createAiClient({ fetch: fakeFetch });
|
||||
expect((await ai.models.list()).map((m) => m.id)).toEqual(['zen5']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
pickBearer,
|
||||
chatCompletionsURL,
|
||||
modelsURL,
|
||||
accountServer,
|
||||
isDocusignEnv,
|
||||
readServerConfig,
|
||||
OAUTH_SCOPES,
|
||||
HANZO_API_BASE_URL,
|
||||
ESIGN_API_VERSION,
|
||||
} from '../src/config';
|
||||
|
||||
describe('pickBearer', () => {
|
||||
it('prefers the pasted API key over the oauth token', () => {
|
||||
expect(pickBearer('hk-123', 'oauth-tok')).toBe('hk-123');
|
||||
});
|
||||
it('falls back to the oauth token when no key', () => {
|
||||
expect(pickBearer('', 'oauth-tok')).toBe('oauth-tok');
|
||||
expect(pickBearer(' ', 'oauth-tok')).toBe('oauth-tok');
|
||||
});
|
||||
it('is empty (anonymous) when neither is present', () => {
|
||||
expect(pickBearer('', '')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gateway URLs', () => {
|
||||
it('build /v1 endpoints on api.hanzo.ai with no /api/ prefix', () => {
|
||||
expect(chatCompletionsURL()).toBe(`${HANZO_API_BASE_URL}/v1/chat/completions`);
|
||||
expect(modelsURL()).toBe(`${HANZO_API_BASE_URL}/v1/models`);
|
||||
expect(chatCompletionsURL()).not.toContain('/api/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountServer / env', () => {
|
||||
it('maps demo and production to the two DocuSign account servers', () => {
|
||||
expect(accountServer('demo')).toBe('https://account-d.docusign.com');
|
||||
expect(accountServer('production')).toBe('https://account.docusign.com');
|
||||
});
|
||||
it('narrows only demo/production', () => {
|
||||
expect(isDocusignEnv('demo')).toBe(true);
|
||||
expect(isDocusignEnv('production')).toBe(true);
|
||||
expect(isDocusignEnv('prod')).toBe(false);
|
||||
expect(isDocusignEnv(undefined)).toBe(false);
|
||||
});
|
||||
it('requests the signature + openid scopes', () => {
|
||||
expect(OAUTH_SCOPES).toContain('signature');
|
||||
expect(OAUTH_SCOPES).toContain('openid');
|
||||
});
|
||||
it('uses eSignature REST v2.1', () => {
|
||||
expect(ESIGN_API_VERSION).toBe('v2.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readServerConfig', () => {
|
||||
const base = {
|
||||
DOCUSIGN_CLIENT_ID: 'ik',
|
||||
DOCUSIGN_CLIENT_SECRET: 'sk',
|
||||
DOCUSIGN_REDIRECT_URI: 'https://docusign.hanzo.ai/oauth/callback',
|
||||
DOCUSIGN_CONNECT_HMAC_KEY: 'hmac',
|
||||
};
|
||||
|
||||
it('reads a full config and defaults env=demo, port=8789', () => {
|
||||
const cfg = readServerConfig(base);
|
||||
expect(cfg.docusignClientId).toBe('ik');
|
||||
expect(cfg.docusignClientSecret).toBe('sk');
|
||||
expect(cfg.connectHmacKey).toBe('hmac');
|
||||
expect(cfg.docusignEnv).toBe('demo');
|
||||
expect(cfg.hanzoApiKey).toBe('');
|
||||
expect(cfg.port).toBe(8789);
|
||||
});
|
||||
|
||||
it('honors DOCUSIGN_ENV=production, HANZO_API_KEY, and PORT', () => {
|
||||
const cfg = readServerConfig({ ...base, DOCUSIGN_ENV: 'production', HANZO_API_KEY: 'hk-x', PORT: '9000' });
|
||||
expect(cfg.docusignEnv).toBe('production');
|
||||
expect(cfg.hanzoApiKey).toBe('hk-x');
|
||||
expect(cfg.port).toBe(9000);
|
||||
});
|
||||
|
||||
it('falls back to demo for an unknown DOCUSIGN_ENV', () => {
|
||||
expect(readServerConfig({ ...base, DOCUSIGN_ENV: 'staging' }).docusignEnv).toBe('demo');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'DOCUSIGN_CLIENT_ID',
|
||||
'DOCUSIGN_CLIENT_SECRET',
|
||||
'DOCUSIGN_REDIRECT_URI',
|
||||
'DOCUSIGN_CONNECT_HMAC_KEY',
|
||||
])('throws when %s is missing', (key) => {
|
||||
const env: Record<string, string | undefined> = { ...base };
|
||||
delete env[key];
|
||||
expect(() => readServerConfig(env)).toThrow(key);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getEnvelope,
|
||||
listDocuments,
|
||||
getDocument,
|
||||
listEnvelopes,
|
||||
parseDocuments,
|
||||
parseEnvelope,
|
||||
COMBINED_DOCUMENT_ID,
|
||||
} from '../src/docusign-api';
|
||||
import { apiBaseUrl } from '../src/docusign-oauth';
|
||||
|
||||
const API = apiBaseUrl('https://demo.docusign.net'); // .../restapi/v2.1
|
||||
const ACCT = 'acct-1';
|
||||
const ENV = 'env-9';
|
||||
const TOK = 'AT-abc';
|
||||
|
||||
describe('request wrappers carry a Bearer and build the exact eSignature paths', () => {
|
||||
it('getEnvelope', () => {
|
||||
const r = getEnvelope(API, ACCT, ENV, TOK);
|
||||
expect(r.url).toBe(`${API}/accounts/acct-1/envelopes/env-9`);
|
||||
expect(r.headers.Authorization).toBe('Bearer AT-abc');
|
||||
});
|
||||
|
||||
it('listDocuments', () => {
|
||||
const r = listDocuments(API, ACCT, ENV, TOK);
|
||||
expect(r.url).toBe(`${API}/accounts/acct-1/envelopes/env-9/documents`);
|
||||
expect(r.headers.Authorization).toBe('Bearer AT-abc');
|
||||
});
|
||||
|
||||
it('getDocument defaults to the combined PDF and asks for application/pdf', () => {
|
||||
const r = getDocument(API, ACCT, ENV, TOK);
|
||||
expect(r.url).toBe(`${API}/accounts/acct-1/envelopes/env-9/documents/${COMBINED_DOCUMENT_ID}`);
|
||||
expect(r.headers.Accept).toBe('application/pdf');
|
||||
expect(r.headers.Authorization).toBe('Bearer AT-abc');
|
||||
});
|
||||
|
||||
it('getDocument targets a specific document id when given', () => {
|
||||
expect(getDocument(API, ACCT, ENV, TOK, '2').url).toBe(`${API}/accounts/acct-1/envelopes/env-9/documents/2`);
|
||||
});
|
||||
|
||||
it('encodes path segments defensively', () => {
|
||||
const r = getEnvelope(API, 'a/b', 'e?x', TOK);
|
||||
expect(r.url).toBe(`${API}/accounts/a%2Fb/envelopes/e%3Fx`);
|
||||
});
|
||||
|
||||
it('listEnvelopes requires a from_date query', () => {
|
||||
const r = listEnvelopes(API, ACCT, TOK, '2026-01-01');
|
||||
const u = new URL(r.url);
|
||||
expect(u.pathname).toBe('/restapi/v2.1/accounts/acct-1/envelopes');
|
||||
expect(u.searchParams.get('from_date')).toBe('2026-01-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDocuments', () => {
|
||||
it('reads envelopeDocuments, sorts by order, drops id-less entries', () => {
|
||||
const docs = parseDocuments({
|
||||
envelopeDocuments: [
|
||||
{ documentId: '2', name: 'Addendum', type: 'content', order: '2' },
|
||||
{ documentId: '1', name: 'MSA', type: 'content', order: '1' },
|
||||
{ name: 'broken' },
|
||||
{ documentId: 'certificate', name: 'Summary', type: 'summary', order: '999' },
|
||||
],
|
||||
});
|
||||
expect(docs.map((d) => d.documentId)).toEqual(['1', '2', 'certificate']);
|
||||
expect(docs[0].name).toBe('MSA');
|
||||
});
|
||||
|
||||
it('returns [] for a body with no documents', () => {
|
||||
expect(parseDocuments({})).toEqual([]);
|
||||
expect(parseDocuments(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEnvelope', () => {
|
||||
it('reads the metadata fields, defaulting missing ones to empty strings', () => {
|
||||
const e = parseEnvelope({ envelopeId: 'env-9', status: 'completed', emailSubject: 'Please sign', sentDateTime: '2026-06-01', completedDateTime: '2026-06-02' });
|
||||
expect(e).toEqual({
|
||||
envelopeId: 'env-9',
|
||||
status: 'completed',
|
||||
emailSubject: 'Please sign',
|
||||
sentDateTime: '2026-06-01',
|
||||
completedDateTime: '2026-06-02',
|
||||
});
|
||||
});
|
||||
it('is total over a sparse body', () => {
|
||||
const e = parseEnvelope({ status: 'sent' });
|
||||
expect(e.envelopeId).toBe('');
|
||||
expect(e.status).toBe('sent');
|
||||
expect(e.emailSubject).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { analyzeEnvelope } from '../src/envelope';
|
||||
import { apiBaseUrl } from '../src/docusign-oauth';
|
||||
import type { PdfDoc, TextRun } from '../src/pdf-text';
|
||||
import type { AiClient } from '../src/client';
|
||||
|
||||
const API = apiBaseUrl('https://demo.docusign.net');
|
||||
const ACCT = 'acct-1';
|
||||
const ENV = 'env-9';
|
||||
|
||||
// Fake DocuSign REST: the envelope metadata GET returns JSON; the combined
|
||||
// document GET returns opaque PDF bytes (the fake pdf loader ignores them).
|
||||
function fakeFetch(): typeof fetch {
|
||||
return (async (url: any) => {
|
||||
const u = String(url);
|
||||
if (u.endsWith(`/envelopes/${ENV}`)) {
|
||||
return new Response(JSON.stringify({ envelopeId: ENV, status: 'completed', emailSubject: 'Master Services Agreement' }), { status: 200 });
|
||||
}
|
||||
if (u.endsWith(`/envelopes/${ENV}/documents/combined`)) {
|
||||
return new Response(new Uint8Array([1, 2, 3, 4]), { status: 200, headers: { 'Content-Type': 'application/pdf' } });
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
// Fake pdf.js loader: two pages of agreement text.
|
||||
const pdfLoader = async (): Promise<PdfDoc> => ({
|
||||
numPages: 2,
|
||||
async getPage(n: number) {
|
||||
const map: Record<number, TextRun[]> = {
|
||||
1: [{ str: 'This' }, { str: 'Agreement' }, { str: 'governs.', hasEOL: true }],
|
||||
2: [{ str: 'Payment' }, { str: 'is' }, { str: 'net-30.', hasEOL: true }],
|
||||
};
|
||||
return { async getTextContent() { return { items: map[n] }; } };
|
||||
},
|
||||
});
|
||||
|
||||
// Fake Hanzo client: echoes the task + how much agreement text it saw.
|
||||
function fakeClient(seen: { userContent?: string }): AiClient {
|
||||
return {
|
||||
chat: {
|
||||
completions: {
|
||||
async create(params) {
|
||||
seen.userContent = (params.messages[1] as { content: string }).content;
|
||||
return { choices: [{ message: { role: 'assistant', content: 'SUMMARY' } }] };
|
||||
},
|
||||
},
|
||||
},
|
||||
models: { async list() { return []; } },
|
||||
};
|
||||
}
|
||||
|
||||
describe('analyzeEnvelope (full pipeline, all effects injected)', () => {
|
||||
it('fetches metadata + combined PDF, extracts text, and runs the action', async () => {
|
||||
const seen: { userContent?: string } = {};
|
||||
const out = await analyzeEnvelope({
|
||||
apiBase: API,
|
||||
accountId: ACCT,
|
||||
envelopeId: ENV,
|
||||
accessToken: 'AT',
|
||||
actionId: 'summarize',
|
||||
fetch: fakeFetch(),
|
||||
pdfLoader,
|
||||
ask: { client: fakeClient(seen) },
|
||||
});
|
||||
|
||||
expect(out.result).toBe('SUMMARY');
|
||||
expect(out.envelope.emailSubject).toBe('Master Services Agreement');
|
||||
expect(out.totalPages).toBe(2);
|
||||
expect(out.includedPages).toBe(2);
|
||||
expect(out.truncated).toBe(false);
|
||||
// the extracted agreement text reached the model, titled by the envelope
|
||||
expect(seen.userContent).toContain('This Agreement governs.');
|
||||
expect(seen.userContent).toContain('Payment is net-30.');
|
||||
expect(seen.userContent).toContain('Agreement: Master Services Agreement');
|
||||
});
|
||||
|
||||
it('surfaces a DocuSign error when the envelope fetch fails', async () => {
|
||||
const failing = (async () => new Response('nope', { status: 401 })) as unknown as typeof fetch;
|
||||
await expect(
|
||||
analyzeEnvelope({ apiBase: API, accountId: ACCT, envelopeId: ENV, accessToken: 'AT', actionId: 'summarize', fetch: failing, pdfLoader }),
|
||||
).rejects.toThrow(/401/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
renderPage,
|
||||
buildDocumentContext,
|
||||
contextNote,
|
||||
buildMessages,
|
||||
ask,
|
||||
listModels,
|
||||
SYSTEM_PROMPT,
|
||||
type PageTextItem,
|
||||
} from '../src/hanzo';
|
||||
import type { AiClient } from '../src/client';
|
||||
|
||||
const pages = (n: number, per: number): PageTextItem[] =>
|
||||
Array.from({ length: n }, (_, i) => ({ page: i + 1, text: 'x'.repeat(per) }));
|
||||
|
||||
describe('renderPage', () => {
|
||||
it('labels a page and keeps its text', () => {
|
||||
expect(renderPage({ page: 3, text: ' hello ' })).toBe('[Page 3]\nhello');
|
||||
});
|
||||
it('renders a header-only block for an empty page', () => {
|
||||
expect(renderPage({ page: 2, text: ' ' })).toBe('[Page 2]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDocumentContext (windowing / truncation)', () => {
|
||||
it('includes every page when under budget and reports not truncated', () => {
|
||||
const ctx = buildDocumentContext(pages(3, 50), 10_000);
|
||||
expect(ctx.totalPages).toBe(3);
|
||||
expect(ctx.includedPages).toBe(3);
|
||||
expect(ctx.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('truncates in order and flags it when over budget', () => {
|
||||
// each rendered page ~ "[Page N]\n" + 500 chars; budget fits ~2 pages
|
||||
const ctx = buildDocumentContext(pages(10, 500), 1100);
|
||||
expect(ctx.totalPages).toBe(10);
|
||||
expect(ctx.includedPages).toBeGreaterThanOrEqual(1);
|
||||
expect(ctx.includedPages).toBeLessThan(10);
|
||||
expect(ctx.truncated).toBe(true);
|
||||
expect(ctx.text.length).toBeLessThanOrEqual(1100);
|
||||
});
|
||||
|
||||
it('hard-cuts a single oversized first page to the budget, always returning something', () => {
|
||||
const ctx = buildDocumentContext(pages(1, 5000), 1000);
|
||||
expect(ctx.includedPages).toBe(1);
|
||||
expect(ctx.truncated).toBe(true);
|
||||
expect(ctx.text.length).toBe(1000);
|
||||
});
|
||||
|
||||
it('handles an empty document', () => {
|
||||
const ctx = buildDocumentContext([], 1000);
|
||||
expect(ctx).toEqual({ text: '', totalPages: 0, includedPages: 0, truncated: false });
|
||||
});
|
||||
|
||||
it('never reorders pages', () => {
|
||||
const ctx = buildDocumentContext([{ page: 1, text: 'AAA' }, { page: 2, text: 'BBB' }], 10_000);
|
||||
expect(ctx.text.indexOf('AAA')).toBeLessThan(ctx.text.indexOf('BBB'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('contextNote', () => {
|
||||
it('is honest about full vs truncated vs empty', () => {
|
||||
expect(contextNote({ text: '', totalPages: 0, includedPages: 0, truncated: false })).toMatch(/No agreement text/);
|
||||
expect(contextNote({ text: 'x', totalPages: 5, includedPages: 5, truncated: false })).toMatch(/full document \(5 pages\)/);
|
||||
expect(contextNote({ text: 'x', totalPages: 10, includedPages: 3, truncated: true })).toMatch(/3 of 10 pages/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMessages', () => {
|
||||
const ctx = buildDocumentContext([{ page: 1, text: 'The parties agree.' }]);
|
||||
it('sets the grounded system prompt and fences the agreement as data', () => {
|
||||
const msgs = buildMessages('Summarize.', ctx, { title: 'MSA', status: 'completed' });
|
||||
expect(msgs[0]).toEqual({ role: 'system', content: SYSTEM_PROMPT });
|
||||
expect(msgs[1].role).toBe('user');
|
||||
expect(msgs[1].content).toContain('---- agreement ----');
|
||||
expect(msgs[1].content).toContain('The parties agree.');
|
||||
expect(msgs[1].content).toContain('Agreement: MSA');
|
||||
expect(msgs[1].content).toContain('Status: completed');
|
||||
expect(msgs[1].content).toContain('---- task ----\nSummarize.');
|
||||
});
|
||||
it('states it is not legal advice', () => {
|
||||
expect(SYSTEM_PROMPT).toMatch(/not legal advice/i);
|
||||
});
|
||||
});
|
||||
|
||||
// A stub AiClient records the request and returns a canned completion.
|
||||
function stubClient(capture: { model?: string; messages?: unknown }): AiClient {
|
||||
return {
|
||||
chat: {
|
||||
completions: {
|
||||
async create(params) {
|
||||
capture.model = params.model;
|
||||
capture.messages = params.messages;
|
||||
return { choices: [{ message: { role: 'assistant', content: 'ANSWER' } }] };
|
||||
},
|
||||
},
|
||||
},
|
||||
models: { async list() { return [{ id: 'zen5' }, { id: 'zen-coder' }]; } },
|
||||
};
|
||||
}
|
||||
|
||||
describe('ask (single call path, injected client)', () => {
|
||||
it('routes the default model and returns the assistant text', async () => {
|
||||
const cap: { model?: string } = {};
|
||||
const out = await ask('Summarize.', buildDocumentContext([{ page: 1, text: 'x' }]), undefined, { client: stubClient(cap) });
|
||||
expect(out).toBe('ANSWER');
|
||||
expect(cap.model).toBe('zen5');
|
||||
});
|
||||
it('honors an explicit model', async () => {
|
||||
const cap: { model?: string } = {};
|
||||
await ask('t', buildDocumentContext([{ page: 1, text: 'x' }]), undefined, { client: stubClient(cap), model: 'zen-coder' });
|
||||
expect(cap.model).toBe('zen-coder');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listModels (injected client)', () => {
|
||||
it('returns the model ids', async () => {
|
||||
expect(await listModels({ client: stubClient({}) })).toEqual(['zen5', 'zen-coder']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
authorizeUrl,
|
||||
basicAuth,
|
||||
tokenExchange,
|
||||
refreshExchange,
|
||||
userInfoUrl,
|
||||
parseTokenResponse,
|
||||
parseUserInfo,
|
||||
defaultAccount,
|
||||
apiBaseUrl,
|
||||
} from '../src/docusign-oauth';
|
||||
import { OAUTH_SCOPES } from '../src/config';
|
||||
|
||||
describe('authorizeUrl', () => {
|
||||
it('builds the demo authorize URL with the documented params', () => {
|
||||
const url = authorizeUrl({
|
||||
env: 'demo',
|
||||
clientId: 'ik-123',
|
||||
redirectUri: 'https://docusign.hanzo.ai/oauth/callback',
|
||||
scopes: OAUTH_SCOPES,
|
||||
state: 'csrf-1',
|
||||
});
|
||||
const u = new URL(url);
|
||||
expect(u.origin + u.pathname).toBe('https://account-d.docusign.com/oauth/auth');
|
||||
expect(u.searchParams.get('response_type')).toBe('code');
|
||||
expect(u.searchParams.get('client_id')).toBe('ik-123');
|
||||
expect(u.searchParams.get('redirect_uri')).toBe('https://docusign.hanzo.ai/oauth/callback');
|
||||
expect(u.searchParams.get('state')).toBe('csrf-1');
|
||||
// scopes are space-joined
|
||||
expect(u.searchParams.get('scope')).toBe('signature openid');
|
||||
});
|
||||
|
||||
it('targets the production account server for env=production', () => {
|
||||
const url = authorizeUrl({ env: 'production', clientId: 'ik', redirectUri: 'https://x/cb', scopes: ['signature'], state: 's' });
|
||||
expect(new URL(url).origin).toBe('https://account.docusign.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('basicAuth', () => {
|
||||
it('is Basic base64(client_id:client_secret)', () => {
|
||||
const header = basicAuth('ikey', 'skey');
|
||||
expect(header.startsWith('Basic ')).toBe(true);
|
||||
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8');
|
||||
expect(decoded).toBe('ikey:skey');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenExchange', () => {
|
||||
it('shapes the code→token request: Basic header + form body, secret not in body/url', () => {
|
||||
const req = tokenExchange({ env: 'demo', clientId: 'ik', clientSecret: 'SECRET', code: 'abc' });
|
||||
expect(req.url).toBe('https://account-d.docusign.com/oauth/token');
|
||||
expect(req.headers.Authorization.startsWith('Basic ')).toBe(true);
|
||||
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('abc');
|
||||
// secret must never leak into the body or URL
|
||||
expect(req.body).not.toContain('SECRET');
|
||||
expect(req.url).not.toContain('SECRET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshExchange', () => {
|
||||
it('shapes the refresh_token request', () => {
|
||||
const req = refreshExchange({ env: 'production', clientId: 'ik', clientSecret: 's', refreshToken: 'rt' });
|
||||
expect(req.url).toBe('https://account.docusign.com/oauth/token');
|
||||
const body = new URLSearchParams(req.body);
|
||||
expect(body.get('grant_type')).toBe('refresh_token');
|
||||
expect(body.get('refresh_token')).toBe('rt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('userInfoUrl', () => {
|
||||
it('is the account-server /oauth/userinfo', () => {
|
||||
expect(userInfoUrl('demo')).toBe('https://account-d.docusign.com/oauth/userinfo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTokenResponse', () => {
|
||||
it('returns the token set on success', () => {
|
||||
const t = parseTokenResponse({ access_token: 'AT', refresh_token: 'RT', expires_in: 28800, token_type: 'Bearer' });
|
||||
expect(t.access_token).toBe('AT');
|
||||
expect(t.refresh_token).toBe('RT');
|
||||
expect(t.expires_in).toBe(28800);
|
||||
});
|
||||
it('throws DocuSign error on a failure body', () => {
|
||||
expect(() => parseTokenResponse({ error: 'invalid_grant', error_description: 'code expired' })).toThrow(/code expired/);
|
||||
});
|
||||
it('throws when access_token is missing', () => {
|
||||
expect(() => parseTokenResponse({ token_type: 'Bearer' })).toThrow(/access_token/);
|
||||
});
|
||||
it('throws on an empty body', () => {
|
||||
expect(() => parseTokenResponse(null)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseUserInfo / defaultAccount / apiBaseUrl', () => {
|
||||
const payload = {
|
||||
sub: 'user-guid',
|
||||
name: 'Ada Lovelace',
|
||||
email: 'ada@example.com',
|
||||
accounts: [
|
||||
{ account_id: 'acct-1', account_name: 'Demo A', base_uri: 'https://demo.docusign.net', is_default: 'false' },
|
||||
{ account_id: 'acct-2', account_name: 'Demo B', base_uri: 'https://demo.docusign.net/', is_default: true },
|
||||
],
|
||||
};
|
||||
|
||||
it('normalizes snake_case accounts to camelCase and strips trailing slash on base_uri', () => {
|
||||
const info = parseUserInfo(payload);
|
||||
expect(info.sub).toBe('user-guid');
|
||||
expect(info.email).toBe('ada@example.com');
|
||||
expect(info.accounts).toHaveLength(2);
|
||||
expect(info.accounts[1].accountId).toBe('acct-2');
|
||||
expect(info.accounts[1].baseUri).toBe('https://demo.docusign.net');
|
||||
expect(info.accounts[1].isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it('picks the is_default account, tolerating string "true"', () => {
|
||||
expect(defaultAccount(parseUserInfo(payload)).accountId).toBe('acct-2');
|
||||
});
|
||||
|
||||
it('falls back to the first account when none is default', () => {
|
||||
const info = parseUserInfo({ ...payload, accounts: [{ account_id: 'only', base_uri: 'https://x.docusign.net', is_default: false }] });
|
||||
expect(defaultAccount(info).accountId).toBe('only');
|
||||
});
|
||||
|
||||
it('drops accounts missing an id or base_uri, throws when none usable', () => {
|
||||
expect(() => parseUserInfo({ accounts: [{ account_id: '', base_uri: 'https://x' }] })).toThrow(/no usable accounts/);
|
||||
});
|
||||
|
||||
it('builds the eSignature REST root at /restapi/v2.1', () => {
|
||||
expect(apiBaseUrl('https://demo.docusign.net')).toBe('https://demo.docusign.net/restapi/v2.1');
|
||||
expect(apiBaseUrl('https://demo.docusign.net/')).toBe('https://demo.docusign.net/restapi/v2.1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseLaunchContext, asPages, panelTitle } from '../src/panel';
|
||||
|
||||
describe('parseLaunchContext', () => {
|
||||
it('reads envelopeId/accountId/title from the launch query', () => {
|
||||
const ctx = parseLaunchContext('?envelopeId=env-9&accountId=acct-1&title=MSA');
|
||||
expect(ctx).toEqual({ envelopeId: 'env-9', accountId: 'acct-1', title: 'MSA' });
|
||||
});
|
||||
it('accepts the snake_case + emailSubject variants', () => {
|
||||
const ctx = parseLaunchContext('?envelope_id=e&account_id=a&emailSubject=Please%20sign');
|
||||
expect(ctx.envelopeId).toBe('e');
|
||||
expect(ctx.accountId).toBe('a');
|
||||
expect(ctx.title).toBe('Please sign');
|
||||
});
|
||||
it('is empty when the panel is opened without context', () => {
|
||||
expect(parseLaunchContext('')).toEqual({ envelopeId: '', accountId: '', title: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('asPages', () => {
|
||||
it('wraps pasted text as a single page', () => {
|
||||
expect(asPages(' hello ')).toEqual([{ page: 1, text: 'hello' }]);
|
||||
});
|
||||
it('is empty for blank text', () => {
|
||||
expect(asPages(' ')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('panelTitle', () => {
|
||||
it('prefers the subject, then the envelope id, then a default', () => {
|
||||
expect(panelTitle({ envelopeId: 'e', accountId: '', title: 'MSA' })).toBe('MSA');
|
||||
expect(panelTitle({ envelopeId: 'e', accountId: '', title: '' })).toBe('Envelope e');
|
||||
expect(panelTitle({ envelopeId: '', accountId: '', title: '' })).toBe('Agreement');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { itemsToText, extractPdfText, type PdfDoc, type TextRun } from '../src/pdf-text';
|
||||
|
||||
describe('itemsToText', () => {
|
||||
it('joins runs with spaces and breaks lines on hasEOL', () => {
|
||||
const runs: TextRun[] = [
|
||||
{ str: 'This' }, { str: 'Agreement' }, { str: 'is' }, { str: 'made', hasEOL: true },
|
||||
{ str: 'between' }, { str: 'the' }, { str: 'parties.', hasEOL: true },
|
||||
];
|
||||
expect(itemsToText(runs)).toBe('This Agreement is made\nbetween the parties.');
|
||||
});
|
||||
|
||||
it('skips marked-content markers (no str)', () => {
|
||||
expect(itemsToText([{ str: 'a' }, {}, { str: 'b' }])).toBe('a b');
|
||||
});
|
||||
|
||||
it('collapses runs of spaces and blank lines', () => {
|
||||
const runs: TextRun[] = [{ str: 'a' }, { str: ' ' }, { str: 'b', hasEOL: true }, { hasEOL: true }, { hasEOL: true }, { hasEOL: true }, { str: 'c' }];
|
||||
const out = itemsToText(runs);
|
||||
expect(out).not.toMatch(/ {2,}/);
|
||||
expect(out).not.toMatch(/\n{3,}/);
|
||||
expect(out).toContain('a');
|
||||
expect(out).toContain('c');
|
||||
});
|
||||
|
||||
it('is total on empty input', () => {
|
||||
expect(itemsToText([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPdfText (injected loader — no real parser)', () => {
|
||||
// A fake pdf.js document: two pages of text runs.
|
||||
const fakeDoc: PdfDoc = {
|
||||
numPages: 2,
|
||||
async getPage(n: number) {
|
||||
const map: Record<number, TextRun[]> = {
|
||||
1: [{ str: 'Page' }, { str: 'one', hasEOL: true }],
|
||||
2: [{ str: 'Page' }, { str: 'two', hasEOL: true }],
|
||||
};
|
||||
return { async getTextContent() { return { items: map[n] }; } };
|
||||
},
|
||||
};
|
||||
|
||||
it('extracts one PageTextItem per page, in order', async () => {
|
||||
const pages = await extractPdfText(new ArrayBuffer(8), async () => fakeDoc);
|
||||
expect(pages).toHaveLength(2);
|
||||
expect(pages[0]).toEqual({ page: 1, text: 'Page one' });
|
||||
expect(pages[1]).toEqual({ page: 2, text: 'Page two' });
|
||||
});
|
||||
|
||||
it('handles a zero-page document', async () => {
|
||||
const empty: PdfDoc = { numPages: 0, async getPage() { throw new Error('no pages'); } };
|
||||
expect(await extractPdfText(new ArrayBuffer(0), async () => empty)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import {
|
||||
computeSignature,
|
||||
constantTimeEqual,
|
||||
signatureHeaders,
|
||||
verifySignature,
|
||||
routeEvent,
|
||||
eventStatus,
|
||||
messageAccountId,
|
||||
messageEnvelopeId,
|
||||
} from '../src/webhook';
|
||||
|
||||
const KEY = 'connect-hmac-key-abc';
|
||||
|
||||
// The reference computation DocuSign performs: base64(HMAC-SHA256(key, rawBody)).
|
||||
function refSig(key: string, body: string): string {
|
||||
return createHmac('sha256', key).update(body, 'utf8').digest('base64');
|
||||
}
|
||||
|
||||
describe('computeSignature', () => {
|
||||
it('is base64(HMAC-SHA256(key, rawBody)) — matches the DocuSign reference', () => {
|
||||
const body = '{"event":"envelope-completed"}';
|
||||
expect(computeSignature(KEY, body)).toBe(refSig(KEY, body));
|
||||
});
|
||||
it('is deterministic and base64', () => {
|
||||
const a = computeSignature(KEY, 'x');
|
||||
expect(a).toBe(computeSignature(KEY, 'x'));
|
||||
expect(a).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constantTimeEqual', () => {
|
||||
it('true for equal, false otherwise, false on different length without throwing', () => {
|
||||
expect(constantTimeEqual('abc', 'abc')).toBe(true);
|
||||
expect(constantTimeEqual('abc', 'abd')).toBe(false);
|
||||
expect(constantTimeEqual('abc', 'abcd')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signatureHeaders', () => {
|
||||
it('collects X-DocuSign-Signature-1..N in order and stops at the first gap', () => {
|
||||
const headers = {
|
||||
'x-docusign-signature-1': 'sig1',
|
||||
'x-docusign-signature-2': 'sig2',
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
expect(signatureHeaders(headers)).toEqual(['sig1', 'sig2']);
|
||||
});
|
||||
it('is empty when no signature header is present', () => {
|
||||
expect(signatureHeaders({ 'content-type': 'application/json' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySignature (trust gate)', () => {
|
||||
const body = JSON.stringify({ event: 'envelope-completed', data: { envelopeId: 'e1' } });
|
||||
const good = refSig(KEY, body);
|
||||
|
||||
it('accepts a request signed with our key (single header)', () => {
|
||||
expect(verifySignature(KEY, [good], body)).toBe(true);
|
||||
});
|
||||
it('accepts when our signature is one of several headers (multiple keys)', () => {
|
||||
expect(verifySignature(KEY, ['some-other-key-sig', good], body)).toBe(true);
|
||||
});
|
||||
it('rejects a tampered body', () => {
|
||||
expect(verifySignature(KEY, [good], body + ' ')).toBe(false);
|
||||
});
|
||||
it('rejects a wrong key', () => {
|
||||
expect(verifySignature('other-key', [good], body)).toBe(false);
|
||||
});
|
||||
it('rejects when no signatures are provided', () => {
|
||||
expect(verifySignature(KEY, [], body)).toBe(false);
|
||||
});
|
||||
it('rejects when the hmac key is empty', () => {
|
||||
expect(verifySignature('', [good], body)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('event routing', () => {
|
||||
it('reads status from the aggregate envelopeSummary shape', () => {
|
||||
expect(eventStatus({ data: { envelopeSummary: { status: 'Completed' } } })).toBe('completed');
|
||||
});
|
||||
it('reads status from the flat shape', () => {
|
||||
expect(eventStatus({ status: 'sent' })).toBe('sent');
|
||||
});
|
||||
|
||||
it('routes a completed envelope to an actionable event with ids + subject', () => {
|
||||
const action = routeEvent({
|
||||
event: 'envelope-completed',
|
||||
data: {
|
||||
accountId: 'acct-1',
|
||||
envelopeId: 'env-9',
|
||||
envelopeSummary: { status: 'completed', emailSubject: 'MSA - please sign' },
|
||||
},
|
||||
});
|
||||
expect(action.kind).toBe('envelope_completed');
|
||||
if (action.kind === 'envelope_completed') {
|
||||
expect(action.accountId).toBe('acct-1');
|
||||
expect(action.envelopeId).toBe('env-9');
|
||||
expect(action.emailSubject).toBe('MSA - please sign');
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores non-completed statuses (still a clean ack)', () => {
|
||||
const action = routeEvent({ event: 'envelope-sent', data: { status: 'sent' } });
|
||||
expect(action.kind).toBe('ignored');
|
||||
if (action.kind === 'ignored') expect(action.status).toBe('sent');
|
||||
});
|
||||
|
||||
it('pulls account/envelope ids from either documented location', () => {
|
||||
expect(messageAccountId({ accountId: 'a' })).toBe('a');
|
||||
expect(messageAccountId({ data: { accountId: 'b' } })).toBe('b');
|
||||
expect(messageEnvelopeId({ data: { envelopeSummary: { envelopeId: 'e' } } })).toBe('e');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -5,6 +5,7 @@ packages:
|
||||
- 'packages/browser'
|
||||
- 'packages/cards'
|
||||
- 'packages/clio'
|
||||
- 'packages/docusign'
|
||||
- 'packages/dxt'
|
||||
- 'packages/gworkspace'
|
||||
- 'packages/jetbrains'
|
||||
|
||||
Reference in New Issue
Block a user