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

This commit is contained in:
Hanzo Dev
2026-07-03 20:32:17 -07:00
32 changed files with 3138 additions and 0 deletions
+188
View File
@@ -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.
```
+96
View File
@@ -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);
});
+44
View File
@@ -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"
}
}
+89
View File
@@ -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);
}
+159
View File
@@ -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 : ''}`;
}
});
+56
View File
@@ -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 });
}
+211
View File
@@ -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());
},
},
};
}
+145
View File
@@ -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,
};
}
+135
View File
@@ -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);
}
+189
View File
@@ -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}`;
}
+88
View File
@@ -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,
};
}
+197
View File
@@ -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);
}
+49
View File
@@ -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>
+15
View File
@@ -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';
+43
View File
@@ -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';
}
+87
View File
@@ -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),
};
};
+244
View File
@@ -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();
}
+48
View File
@@ -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; }
+143
View File
@@ -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 };
}
+62
View File
@@ -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/);
});
});
+94
View File
@@ -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']);
});
});
+94
View File
@@ -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('');
});
});
+84
View File
@@ -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/);
});
});
+121
View File
@@ -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']);
});
});
+136
View File
@@ -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');
});
});
+35
View File
@@ -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');
});
});
+55
View File
@@ -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([]);
});
});
+115
View File
@@ -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');
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
},
});
+1
View File
@@ -5,6 +5,7 @@ packages:
- 'packages/browser'
- 'packages/cards'
- 'packages/clio'
- 'packages/docusign'
- 'packages/dxt'
- 'packages/gworkspace'
- 'packages/jetbrains'