Files
zeekay 3578254c60 Tie a webhook's eSign identity to its team as well as its URL, and follow no redirect with it
HANZO_WEBHOOKS entries are <team url>=<url>: another team pointing at a listed URL is posted as a stranger. A call made as eSign does not follow a redirect, so the token reaches the listed URL and nowhere else.
2026-10-20 18:21:45 -07:00

216 lines
12 KiB
Markdown

# Hanzo Sign
## Overview
The Open Source DocuSign Alternative.
## Tech Stack
- **Language**: TypeScript/JavaScript
## Build & Run
```bash
npm install && npm run build
npm test
```
## Structure
```
sign/
AGENTS.md
ARCHITECTURE.md
CLA.md
CODE_OF_CONDUCT.md
CODE_STYLE.md
CONTRIBUTING.md
LICENSE
LLM.md
MANIFEST.md
README.md
SIGNING.md
WRITING_STYLE.md
apps/
assets/
build.log
```
## Key Files
- `README.md` -- Project documentation
- `package.json` -- Dependencies and scripts
## Routes: everything is /v1
Every route is served and called under /v1; `npm run lint:paths`, the first
step of the gate, fails on any other API prefix. The mount is
`apps/remix/server/router.ts`:
| path | what |
|---|---|
| `/v1/rest/*` | resource API, the ts-rest contract (`packages/api/v1`), spec at `/v1/rest/openapi.json` |
| `/v1/rpc/*` | operation API, ZAP over JSON-over-HTTP (`server/zap/http-api.ts`), spec at `/v1/rpc/openapi.json` |
| `/v1/auth/*` | session auth (`packages/auth/server`) |
| `/v1/files/*`, `/v1/ai/*`, `/v1/jobs/*` | uploads and downloads, AI detection, the job runner |
| `/v1/health`, `/v1/limits`, `/v1/locale`, `/v1/theme`, … | RR7 resource routes (`app/routes/v1+`) |
| `/auth/callback` | Hanzo IAM sign-in return |
| `/zap` | the browser's ZAP WebSocket |
The two REST surfaces are named for what they are, not versioned, so neither
shadows the other.
`/auth/callback` is the one browser callback path IAM derives for every host in
universe `charts/app/values/hanzo/iam-provision.yaml`, so the Hanzo
sign-in needs no per-app redirect_uri; other OAuth providers return to
`/v1/auth/callback/<provider>`.
## Inside a transaction, only `tx`
Base SQLite serves one write connection. `prisma.$transaction(async (tx) => …)`
holds it for as long as the callback runs, so a call in the callback that
reaches the global Prisma client queues behind a lock that call is itself
blocking. It waits out the busy timeout and the transaction dies with `P2028
Transaction already closed` — at runtime, on a green build. This is what stopped
every document creation at 0.1.4.
Mail and other calls out of the process do not deadlock, but they hold that one
connection open across the network: every other write waits on a mail server,
and a slow send reaches the same timeout by a longer road.
Jobs, webhooks, notifications and mail are side effects of committed state:
return what they need from the callback and fire them after the commit. A
failed send therefore no longer rolls back the record it announces — the record
stands, and the send is reported where it happens. Where a transaction wrapped
one write and a send, it was doing nothing: keep the order, drop the
transaction, and a single write is still atomic on its own.
`packages/eslint-config/transaction.cjs` holds the rule. Two callers read it:
the shared config, so an editor says it while you type, and `npm run lint:tx`,
which runs it alone over every package and app in seconds and is the first
thing the CI gate does. `npm test -w @hanzo/esign-eslint-config` proves the
rule still fires, so it cannot rot into a no-op.
It asks three questions of a callback: is the client called `tx`, does the
callback name a client that is not `tx`, and does it await a call it did not
hand `tx`. The third is the general one — handing `tx` on is how a call says it
joins the transaction, so awaiting one that never mentions it is awaiting the
world outside while holding the door. That covers jobs, webhooks, mail and any
other I/O under one question, and it sees through a helper the earlier
name-matching could not: what is checked is the call site's shape, not its
callee's name.
## In-process cloud fold (HIP-0106, task #100)
The core server-side e-signature flow is ALSO shipped as a self-contained,
ESM-free **goja bundle** so the unified `hanzoai/cloud` binary runs it in-process
(TS-on-`dop251/goja` + Hanzo Base/SQLite) — no Next.js/Remix, no Prisma, no
Postgres. This replaces the standalone esign pod.
- `goja/bundle.js` — the ported domain: documents, recipients, fields, the
signing flow/state machine, audit trail and completion. Exposes
`globalThis.handle({route,method,params,body,tenant})`. It carries LOGIC only.
- `embed.go` / `go.mod` — a std-lib-only Go module (`github.com/hanzoai/esign`,
`Bundle()`) that cloud imports to `go:embed` the bundle.
- `goja/README.md` — the host contract. It runs on the reusable
`hanzoai/cloud/clients/gojabase` RW-Base host: persistence (`globalThis.__db`,
per-tenant SQLite, one txn per dispatch) + `__newId`/`__now` come from gojabase;
the PDF/PKI seal primitives (`globalThis.__pdf`: pdfcpu render + x509/PKCS#7
sign) are injected by `clients/sign` via gojabase `Config.HostFns`. Only the
crypto/PDF primitive is Go; the flow + seal orchestration stay here in JS.
Edit a server-side rule? Mirror it in `goja/bundle.js` (the cloud path) as well
as the Remix/tRPC handlers.
## Several recipients, and what a send needs in production
- cmdk 1 writes `data-disabled="false"` on every enabled item. `CommandItem`
(`packages/ui/primitives/command.tsx`) disables only on `data-[disabled=true]`;
matching the bare attribute made every combobox item ignore the mouse, so the
fields step could not select a second recipient and the send refused for want
of their signature field.
- The recipients form learns server ids only on a step change. The editor
provider keeps them by the row's `formId`, sent as `clientId` and echoed back
(`envelope.recipient.set`), so a recipient created by one autosave is updated,
not deleted and recreated, by the next. `envelope-recipients.spec.ts`
("several recipients in one visit") holds both.
- A recipient has no account. The `/zap` socket opens without a session for the
app's own pages as an anonymous connection (`mint.ts`), and the dispatcher lets
it reach only `publicRoutes` (`zap/server/public-routes.ts`: the routes tRPC
served as public procedures, each authorised by the token it carries; their
handlers take `ZapConnectionContext`). Before this the socket refused them and
no recipient without an account could sign a field. `/v1/rpc` still needs a
token. `packages/trpc/__tests__/anonymous.test.ts` holds it.
- Sealing a completed document signs it with the certificate the local signer
reads: `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` (base64 p12) and
`NEXT_PRIVATE_SIGNING_PASSPHRASE`, from KMS `hanzo/sign@prod`. The image has
none; without them every completion fails at the seal. An unset
`NEXT_PRIVATE_SIGNING_TRANSPORT` is `local` for the signer and for
`/v1/health` alike.
- Mail goes through notify (`packages/email/transports/notify.ts`): the sender
is the org's credential (no-reply@send.hanzo.ai), and notify carries no
attachment and no reply-to. The completed email reaches every recipient with
its download link (`/sign/<token>/complete`), not the PDF attached.
## The look: hanzo.ai's
- Tokens are `packages/ui/styles/theme.css`, values from @hanzo/ui DESIGN.md: dark is a
#000 page, #0a0a0a surfaces, #171717 raised, #ededf1 ink, hairlines at white 10% (a
control's edge 15%); light is white, #0a0a0a ink, black 10% and 15%. Every value is an
opaque `H S% L%` triplet, because the Tailwind colours are `hsl(var(--x))` and an alpha
inside the variable breaks every `/50` modifier. Monochrome: the recipient tones and the
destructive red are the only colour, and both are functional.
- Type is Zen and Zen Mono from `@hanzo/font` (`app.css` imports `@hanzo/font/css`), with
Zen's weight ladder in `packages/tailwind-config` (normal 497, medium 606, semibold 680,
bold 760). The server-side PDF renderers keep their own Inter files in `public/fonts`.
- Buttons are pills (`packages/ui/primitives/button.tsx`); the primary is the ink colour,
white on black or black on white. A tall option card built from a Button says
`rounded-2xl` itself.
- The brand is the wordmark alone, as text: `BrandingLogo` renders
`NEXT_PUBLIC_APP_NAME` (default "Hanzo eSign", `components/branding/brand.ts`) and is
sized by the caller's `text-*` class, so no tenant name is clipped. There is no mark.
Emails and the sealed PDF's certificate and audit log draw `public/static/logo.png`
(copy in `packages/assets/static`), a raster of the same wordmark in Zen; redraw it if
the default name changes.
- `PublicHeader` (in `app-header.tsx`) and `AppFooter` frame every page a visitor without
a session sees: sign-in and the other unauthenticated pages, a recipient's result
pages (`/sign/<token>/complete`, expired, rejected, waiting) and public profiles. The
signing and direct-template pages keep their own bar and no footer.
- Theme: a stored choice (`/v1/theme`) wins; with none, remix-themes follows the device,
dark unless it asks for light, as hanzo.ai does.
- Prettier's Tailwind plugin finds the hoisted Tailwind 4 and fails on this tree; format
with a config that sets `tailwindConfig` to `apps/remix/tailwind.config.ts`.
## One way in: an IAM application's token
People sign in; the API is for services. A call without a session carries a Hanzo IAM
ACCESS token (`tokenType` access-token, never a sign-in's id_token) that an APPLICATION
minted for this app: signed by a key in hanzo.id's JWKS, issued by hanzo.id, `aud` =
`IAM_CLIENT_ID` (RFC 8707 `resource`), unexpired (`server-only/iam/token.ts`,
`verifyIamToken`). `public-api/get-bearer.ts` is the one resolver behind `/v1/rpc`
(mint.ts), `/v1/rest` and the download routes, and this server mints no tokens of its own.
`IAM_APP_TEAMS` (`<client id>:<team url>`, comma-separated) is the whole grant: the
token's `azp` must be listed, and the application acts for exactly that one team, as
its organisation owner, audited under its IAM subject (`admin/hanzo-cloud`). Since
`resource` lets any authenticated client mint `aud` = this app, the audience proves
nothing alone; the list does. Empty is nobody.
The other way round, this app calls the Hanzo API as itself: `Machine` mints its
client_credentials token for the Hanzo API (`resource` https://api.hanzo.ai), never for
itself, and refuses any bearer its own client minted. notify takes it, and so does a
webhook whose team and exact URL `HANZO_WEBHOOKS` lists together (`<team url>=<url>`;
`webhooks/send.ts`, the one sender for sends and resends, following no redirect), so the
receiver verifies eSign against hanzo.id and holds no secret; what it answers is not kept
for the team to read. A webhook anywhere else is sent no
identity, only its own secret: a team chooses where its webhook points. Zapier, which could only carry an API token, is gone.
An API token is still read in exactly one place: embed authoring signs its presign
token with one (`embedding-presign/api-token.ts`).
## Shipping and what is still broken
- The pin is universe `charts/app/values/hanzo/sign.yaml` (image
`oci.hanzo.ai/hanzoai/esign:<semver>@<digest>`; CI publishes
`ghcr.io/hanzoai/esign:<semver>` and `sha-<short>-amd64` through the door). CD
syncs `hanzo-sign` only by hand: `POST /v1/platform/apps/sign/sync?org=hanzo` as
admin. The signing certificate is the KMSSecret `sign-env-kms-sync` (KMS hanzo
`/sign@prod`), read by the operator under its grant in `kms-grants.yaml`.
- The investor SAFE template is built from hanzoai/deck `safe/`; the intake that
sends it is hanzo-inc/cloud `POST /v1/marketing/leads` (program `investor`), calling
`/v1/rpc` as cloud's own IAM application, which `IAM_APP_TEAMS` maps to the team
that holds the template. The team's webhook posts back to
`/v1/marketing/leads/esign` as this app's IAM identity.
- Broken, found and not fixed: deleting an account fails ("Deleted account service
account not found"): the SQLite `0_init` never created the deleted-account
service user the delete hands envelopes to. `/admin/documents/<id>` answers 500.
On `/v1/rpc` the admin routes' operationIds (`admin.documentDelete`) are not the
handler names (`admin.document.delete`), so they 404; the socket reaches them.