1762 Commits
Author SHA1 Message Date
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
zeekay 31a45e2826 Post a webhook as eSign only to a URL HANZO_WEBHOOKS lists exactly
A team chooses where its webhook points, so the origin was not enough: an admin
could aim eSign's identity at any Hanzo API route and read the answer in the
webhook log. One sender now serves sends and resends: a URL HANZO_WEBHOOKS
names exactly is posted with eSign's IAM token and its answer is not kept;
every other URL gets the webhook's own secret and nothing of ours.

notify's token is minted for the Hanzo API rather than for eSign itself, and a
bearer eSign's own client minted is refused.
2026-10-20 18:18:40 -07:00
zeekay 34194a1495 Admit only an application IAM_APP_TEAMS names, for its one team
An audience proves nothing alone: any client IAM authenticates may mint a token
for this app's resource. So the bearer is an application's access token whose
azp IAM_APP_TEAMS lists, and it acts for exactly the team that list maps it to,
as that team's organisation owner. People sign in; the person bearer and the
X-Team-Id choice are gone, and so is IAM_ORG. A sign-in's id_token is never a
bearer.

A webhook carries eSign's identity only to the Hanzo API, minted for it; any
other endpoint gets the webhook's own secret and nothing of ours.
2026-10-20 18:14:18 -07:00
zeekay 2fc1aa3bbe Take an IAM token as the only bearer, and call out as eSign's own IAM identity
A call without a session carries a Hanzo IAM access token: signed by a key in
hanzo.id's JWKS, issued by hanzo.id, minted for this app (aud = IAM_CLIENT_ID)
and unexpired. One resolver, getBearer, sits behind /v1/rpc, /v1/rest and the
download routes, and acts in the team X-Team-Id names: a person as the account
their sign-in linked, an application of IAM_ORG as the team's organisation
owner. API tokens no longer get into the API; Zapier, which could carry nothing
else, is deleted.

Outbound, one Machine mints eSign's client_credentials token for the service it
is spent at. notify uses it as before, and every webhook now carries one minted
for its endpoint's origin, so a Hanzo receiver verifies the caller against
hanzo.id instead of holding a shared secret.
2026-10-20 18:04:07 -07:00
zeekay b2083a6610 Say Hanzo eSign wherever the product names itself
Emails, the signing page's title, the sealed PDF's reason, the sign-in
refusal and the dialogs still said Hanzo Sign. The catalogs change with
the strings, message for message, so every translation keeps its id.
2026-10-20 13:12:22 -07:00
zeekay 9d66337dec Show the brand as the Hanzo eSign wordmark alone, under hanzo.ai's bar
The wordmark was an SVG that guessed its own width, and clipped the name
to "Hanzo Sigı". It is now text: NEXT_PUBLIC_APP_NAME, "Hanzo eSign" by
default, measured by the browser and sized by the caller, with no mark in
front of it anywhere. The mobile menu and the public profile header drew
the upstream Documenso logo; the emails and the sealed PDF's certificate
and audit log drew it too, from static/logo.png, which is now the
wordmark set in Zen.

The app header is hanzo.ai's: the wordmark at the left, quiet nav, pill
controls at the right, a hairline under it. A visitor without a session
sees the same bar with the wordmark alone, and a footer with the
copyright and hanzo.ai's terms and privacy policy, on sign-in, the other
unauthenticated pages, a recipient's completion and result pages and
public profiles. The signing pages keep their own bar, which now shows
the wordmark at every width.
2026-10-20 12:47:20 -07:00
zeekay 597cfae467 Set the app in Zen on hanzo.ai's palette, with pill buttons
The theme takes @hanzo/ui DESIGN.md's values: a true-black page, #0a0a0a
surfaces, #171717 raised, #ededf1 ink and white-alpha hairlines in dark;
white, #0a0a0a ink and black-alpha hairlines in light. Each is an opaque
triplet, so Tailwind's alpha modifiers still apply.

Type is Zen and Zen Mono from @hanzo/font, at Zen's own weights. Buttons
are pills, the primary in the ink colour; the option cards built from a
Button keep a card's corners, and the folder actions wrap on a phone
rather than breaking a pill over two lines.
2026-10-20 12:47:20 -07:00
zeekay be1d26a502 Let a recipient without an account sign
The socket the app's pages call over (/zap) opened only for a session, so
a recipient signing by their link, who has no account, could not reach
the routes that sign a field or complete a document: every field they
clicked failed with "An error occurred while signing the field". tRPC had
served those as public procedures; the port to ZAP kept their bodies and
lost the door.

A connection from the app's own pages with no session now opens as
anonymous and reaches only the routes tRPC served publicly, each
authorised by the token it carries; every other route answers it 401
before its handler runs. The operation API still refuses a request
without a token.
2026-10-20 12:41:58 -07:00
zeekay 1d81701d4e Report the signing certificate missing when the signer would miss it
The signer takes an unset NEXT_PRIVATE_SIGNING_TRANSPORT as local and
reads the certificate; the health check took it as something else and
answered ok. Production had no certificate and said so nowhere.
2026-10-20 12:05:09 -07:00
zeekay c8bea1f9af Keep a recipient's id across autosaves
The form learns server ids only on a step change, so each autosave after
the first sent the recipients it had just created without an id: the
server deleted them and created them again, with new ids and signing
tokens. The provider now keeps the id it got back for each row's formId,
sent as clientId and echoed by envelope.recipient.set.
2026-10-20 12:05:09 -07:00
zeekay d4121a9ece Let the mouse pick a combobox item again
cmdk 1 writes data-disabled="false" on every enabled item, and the item
matched the bare attribute, so every item in every combobox took
pointer-events: none. The fields step could not select a second
recipient, so the second recipient got no signature field and the send
refused.
2026-10-20 12:05:09 -07:00
zooqueenandHanzo Dev 15003d6736 The repository, Go module and image are named esign
github.com/hanzoai/esign is the module path and ghcr.io/hanzoai/esign the
image; links that named the old repository, or the upstream image names the
scrub left behind, name hanzoai/esign. The security contact's canonical
address is esign.hanzo.ai.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 13:03:51 -07:00
zooqueenandHanzo Dev 63fcf10df4 Resend an invitation as a fresh one from the resender
Every invitation row comes from newInvitation: its own id and token, sent
now, by the person sending it. A resend replaces the pending invitation
with a new one from the resender, so the old link stops working and the
sender who vouches for it is the one who sent it last. Only a pending
invitation is resent.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 06:51:56 -07:00
zooqueenandHanzo Dev 472b2a4643 Admit by one rule on every sign-in and every use of a credential
A person is admitted when IAM says they administer the app's org, or when
an ADMIN vouches for them: through a membership that came from that ADMIN's
invitation, or through such an invitation pending for their verified
address. The rule (isAdmitted, with vouches: an ADMIN who is not disabled)
decides every IAM sign-in, linked or new. A linked account that fails it is
refused and its sessions and API tokens are deleted.

Sessions and API tokens work only while their holder is linked to IAM, not
disabled and admitted (credentialIsCurrent over readStanding); a team
token's holder is the organisation's owner. Accepting an invitation needs
its sender to vouch for it now, and the membership records that sender as
the member's inviter.

The cutoff is the migration that ships with this: 1_iam_only_sign_in
deletes every session and API token and adds OrganisationMember.invitedById
beside OrganisationMemberInvite.inviterId, NULL for every row already
standing, so nothing from before it vouches for anyone. IAM_ONLY_SINCE goes.
IAM_PROVIDER names the one provider in lib.

The e2e fixture admits its users through one ADMIN voucher. Tests cover the
decision for returning and first sign-ins, vouching, credentials, and the
migration against real SQLite.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 06:51:56 -07:00
zooqueenandHanzo Dev 6ac5643e31 Return a sign-in only to a path on this app
normalizeReturnTo accepts a path, or an absolute URL on the app's origin as
its path, and refuses anything a browser could read as another host: a
path that begins // or /\ once dot segments and encodings are resolved,
and any backslash or control character. isValidReturnTo is the same
question.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 02:52:18 -07:00
zooqueenandHanzo Dev 07004509a7 Keep the Hanzo IAM link on every account
It is the only way to sign in, so the account route refuses to delete it and
the linked-accounts page offers no unlink for it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 02:52:18 -07:00
zooqueenandHanzo Dev b0187980ef Send cookies Secure over https and act on a session only from the app
useSecureCookies follows the app URL's scheme alone, so every https
deployment sets Secure (and the __Secure- name) whatever NODE_ENV says. The
session cookie is SameSite=Lax: it rides top-level navigation, the return
from IAM included, and no cross-site subrequest.

The ZAP mint, which authenticates both /zap and /v1/rpc, accepts a session
cookie only when Origin is the app's own; a bearer token is unaffected.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 02:52:18 -07:00
zooqueenandHanzo Dev 1fa3aaa4a4 Honour only what IAM-only sign-in issued, and let only ADMINs bring people in
A returning person is found by their linked IAM identity and let in unless
disabled, before anything else is asked; IAM has already authenticated
them. A verified address is required only to make an account. Each sign-in
restates IAM's answer on the account: whether its address is verified, and
ADMIN exactly when IAM says the person administers the app's org.

IAM_ONLY_SINCE is this commit's instant. Sessions, API tokens and
invitations created before it were issued under the old sign-in rules: the
sessions and tokens are refused, and the invitations make no account. A
session also needs a user who is not disabled and is linked to an IAM
identity, and it lives 30 days from the sign-in that made it, never
extended. An API token held by a disabled user is refused.

Creating an organisation, inviting and resending an invitation require
ADMIN. An invitation records its sender (OrganisationMemberInvite.inviterId,
migration 1_organisation_member_invite_inviter), and makes an account only
while that sender holds ADMIN and is not disabled. The reset-token reader
left from the removed password flow goes too.

The decisions are pure functions with their own tests; the e2e fixture
links its users to an IAM identity, as a session now requires.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 09:51:53 +00:00
zooqueenandHanzo Dev ecee5e40aa Sign in through Hanzo IAM only, and make accounts for invited people only
The IAM callback at /auth/callback reads IAM's userinfo answer and refuses
an address IAM has not verified. An account is found by its linked IAM
identity alone (provider hanzo, the IAM subject); an existing account that
only shares the address is refused, never merged. A first sign-in makes an
account only for an admin of the org the application serves or for someone
an organisation invited at that verified address. Every refusal returns to
/signin with a sentence saying what to do.

Accepting an organisation invitation joins the signed-in person, and only
when their verified address is the invited one; opening the link no longer
adds whoever holds the address.

The local email/password sign-up, sign-in, reset, verify-email and update
routes, pages, jobs and mail are gone, as are passkey sign-in, the Google,
Microsoft and generic OIDC providers, the per-organisation SSO portal and
its account-link flow. Passkeys and two-factor stay for confirming a
signature. The e2e suite starts sessions the way a finished IAM sign-in
leaves them.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 01:52:24 -07:00
zeekay d6fa542992 Bump insights to 1.358.2 for /v1 endpoints
Claude-Session: https://claude.ai/code/session_011i2AG9nwpiKUCVxAdDzYuU
2026-09-29 20:52:41 -07:00
zeekay 2297f53665 Import Lingui macros from the entrypoints Lingui 6 has
@lingui/macro is the Lingui 5 package, and its macro is not one the
Lingui 6 babel plugin can run, so the server build stopped on the two
files still importing it.

Claude-Session: https://claude.ai/code/session_011i2AG9nwpiKUCVxAdDzYuU
2026-09-29 18:55:00 -07:00
zeekay ccb96089d5 Drop the print rule no browser applies
An @page nested in a style rule is not CSS: browsers ignore it, and
lightningcss refuses to minify the stylesheet around it. The PDF
routes set their own page size, so nothing printed changes.

Claude-Session: https://claude.ai/code/session_011i2AG9nwpiKUCVxAdDzYuU
2026-09-29 18:52:04 -07:00
zeekay e9821244a3 Spell the card's primary shadow with theme()
var(colors.primary.DEFAULT/70%) is not CSS, so browsers dropped the
whole shadow and lightningcss refuses to minify the stylesheet.

Claude-Session: https://claude.ai/code/session_011i2AG9nwpiKUCVxAdDzYuU
2026-09-29 18:40:15 -07:00
zeekay dd53ab7f3e Build the app on the Tailwind its config is written for
The app's config and stylesheet are Tailwind 3: @tailwind directives,
a plugin reading tailwindcss/lib/util/flattenColorPalette, and the
plugin handed to PostCSS directly. Tailwind 4 refuses all three, so
the image build stopped at the stylesheet. The docs app stays on 4.

Claude-Session: https://claude.ai/code/session_011i2AG9nwpiKUCVxAdDzYuU
2026-09-29 18:36:02 -07:00
zeekay d902501484 Generate the Prisma client without a database URL
prisma generate loads prisma.config.ts, and env() throws when
DATABASE_URL is unset, so every build without a database stopped at
prebuild. generate needs no database; migrate still refuses an empty
URL.

Claude-Session: https://claude.ai/code/session_01TUxvfuta1Z44KVNYuAz71F
2026-09-24 15:05:12 -07:00
zeekay d3e091aa97 Serve and call every route under /v1
The resource API is /v1/rest and the operation API is /v1/rpc: named
for what they are, so neither shadows the other, and the v2-beta alias
is gone. Hanzo IAM returns to /auth/callback, the callback path IAM
already derives for this host, so sign-in needs no IAM change.

Claude-Session: https://claude.ai/code/session_01TUxvfuta1Z44KVNYuAz71F
2026-09-24 14:52:07 -07:00
zeekayandHanzo Dev 11c0b1595d docs: word three comments so a grep for casts does not count them
The suppression count for this work is `git grep -c` over '@ts-ignore',
'@ts-expect-error', 'as any', 'as unknown as', ': any' and eslint-disable.
Three comments added in it matched as prose: a quoted index signature in
the v1 contract, "over: any string" in the upload test and "has any meta"
in the field tooltip. They say the same thing in words that do not match,
so the counts read 10 `as any` and 8 `: any`, as before this work.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 05:13:04 -07:00
zeekayandHanzo Dev 32a732ba95 fix(lib): say what the clipboard hook checks, as it has always checked it
`Boolean(typeof ClipboardItem && navigator.clipboard.write)` looks like a
check for ClipboardItem, but `typeof` always yields a non-empty string, so
the expression was always navigator.clipboard.write — ESLint 10's
no-constant-binary-expression says so. The check now says only that, which
is what it has always evaluated to, and a comment records why that is
enough: without ClipboardItem, handleClipboardApiCopy throws inside its own
try and falls back to writeText. The ternary used as a statement becomes an
if, as typescript-eslint 8's no-unused-expressions asks.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 05:07:02 -07:00
zeekayandHanzo Dev a7a65d4095 fix(zap): mark the promise-returning functions async, and the two that never wait not
The lint config makes @typescript-eslint/promise-function-async and
require-await errors. Seven functions in the ZAP React hooks return a
promise without being async — the query and mutation functions and three
cache utilities — and two route handlers are async with nothing to await:
both only log and throw NOT_IMPLEMENTED.

The seven are async. A synchronous throw inside them would now reject
rather than throw, which TanStack Query already turns every queryFn and
mutationFn throw into, and the utilities return what they returned. The two
handlers drop async. The dispatcher is their only caller and awaits them
inside one try, which catches a thrown error and a rejected promise alike.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 05:06:51 -07:00
zeekayandHanzo Dev b54e5cef23 fix(lint): drop assignments nothing reads
ESLint 10's recommended set added no-useless-assignment, which fails on a
value no later statement can read. Fourteen places had one:

- An initial value every path overwrites before reading: `let x = null`
  ahead of a try that assigns it or leaves, the webhook response body both
  branches set, the rate-limit loop's counter, the avatar's previous id and
  the license response. They are declared without one; TypeScript still
  checks each is assigned before use.
- Konva stages and layers set to null after destroy(), just before the
  function returns and the variable goes out of scope. The nulls go, and
  the variables are const.
- Two in the v1 PDF field writer: textFieldBoxY recomputed after the only
  read of it had already been copied into adjustedFieldY, and a line reset
  to '' that both following branches overwrite.
- The folder breadcrumbs' currentFolderId, reassigned in a loop that walks
  by currentFolder.parentId; the first query reads folderId directly.

No output changes: each removed value was provably never read.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 05:04:46 -07:00
zeekayandHanzo Dev 87f33903e2 fix(lint): keep the caught error as the cause of the one rethrown
ESLint 10's recommended set added preserve-caught-error: a catch block that
throws a new error has to pass the caught one as its cause. Five places
caught an error and threw a plainer one in its place — fetching the OIDC
configuration, sending a confirmation email, triggering a webhook,
validating a Zapier API token, rendering an email — and each failed the
lint.

Each now passes `{ cause: err }`. The thrown error keeps its message and
type; what it gains is the original, which Node prints after it as
`[cause]`. render-email-with-i18n.tsx also loses its stale ban-types
disable comment, as the five files in the commit before did.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 05:02:03 -07:00
zeekayandHanzo Dev e00578bc1e fix(lint): drop disable comments for a rule typescript-eslint 8 removed
Six lines disabled @typescript-eslint/ban-types over `string & {}`, the
union that keeps a string type's known values in autocomplete.
typescript-eslint 8 removed ban-types, and ESLint 10 reports a disable
comment for a rule that does not exist as an error — "Definition for rule
'@typescript-eslint/ban-types' was not found" — so each of them failed the
lint. Its successors do not flag `{}` inside an intersection, so with the
comments gone nothing is reported on those lines. Five go here; the sixth,
in render-email-with-i18n.tsx, goes with that file's other lint fix.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 05:00:55 -07:00
zeekayandHanzo Dev 1493fae287 test(e2e): type the specs against the schema and types they run on
packages/app-tests has no typecheck script, and 10 errors had collected in
its specs. None changes what a spec sends or checks:

- Two API specs map over create-many's JSON, which Playwright types as any,
  so the callback's parameters were implicitly any. The responses are typed
  as TCreateEnvelopeRecipientsResponse, as the file already types its other
  responses.
- A PDF built with Buffer.from(Uint8Array) is a Buffer<ArrayBuffer>; its
  helpers said Buffer, which @types/node 26 reads as possibly shared memory,
  and a Blob takes no shared memory.
- The seed's updateDocumentOptions was typed as Prisma's unchecked update
  input, which has no relation fields, while the dictation specs upsert
  documentMeta through it — which Prisma takes as the checked input. It is
  typed as EnvelopeUpdateInput; every caller passes fields that input has.
- A spec typed a seeded document as Document, a model the schema replaced
  with Envelope.
- The alignment spec picked each field's type and fieldMeta apart, which
  loses which meta goes with which type; it drops the two keys the payload
  does not take and spreads the rest, and types the payload as the schema's
  input, which is what a client sends.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:57:58 -07:00
zeekayandHanzo Dev 7dd57063ba test(lib): set the upload transport as the raw environment does
upload.test.ts feeds NEXT_PUBLIC_UPLOAD_TRANSPORT values that are not a
transport, to check each one throws. process-env.d.ts declares the variable
as 'database' | 's3', so assigning any other string failed packages/lib's
typecheck; it did before the version sweep too. The test writes through
process.env's string index instead, which is what the process hands over.
3 of 3 pass, as before.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:53:30 -07:00
zeekayandHanzo Dev 5706731b88 chore(lib): delete two queries on columns the schema no longer has
getCompletedFieldsForDocument filtered fields by documentId and
getActiveSubscriptionsByUserId filtered subscriptions by userId. Fields
belong to an envelope and subscriptions to an organisation, and neither
column exists — they were already gone before the version sweep — so both
failed packages/lib's typecheck, and either would have thrown if called.
Nothing calls either.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:53:29 -07:00
zeekayandHanzo Dev 141910c4b7 fix(api): write the v1 contract as a plain route object
The v1 contract went through ts-rest's c.router with the authorization
header as baseHeaders. @ts-rest/core 3.52's router types merge that header
through zod 3 types — AnyZodObject, objectUtil.MergeShapes, a ZodObject of
three type arguments — which zod 4 does not have. Behind skipLibCheck each
one is `any`, and the route types they build collapse to
`{ [x: string]: any; method; query }`: no path, no body, no responses. Every
handler lost its params, query and body — 56 errors in the implementation —
and every example's client call stopped being callable.

The routes are now a plain object `as const satisfies AppRouter`, each
naming the header itself. At run time c.router did nothing else here: it
merged baseHeaders into routes that had none, which is the header schema
itself, and copied responses. ts-rest's server and client types read the
plain object correctly under zod 4, and all 19 routes type their handlers.

Two examples were wrong once their types could be read: sendDocument
requires a body — `{}` takes the defaults the server would apply — and
createField answers `{ fields, documentId }`, not a field, so the example
reads the field out of `fields`.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:48:04 -07:00
zeekayandHanzo Dev 60f1747270 fix(api): describe v1 schemas with zod 4's meta, not a zod 3 extension
The v1 API's schemas called extendZodWithOpenApi(z) from
@anatine/zod-openapi at load and then .openapi({...}) on six fields.
anatine 1.14 is written for zod 3: it patches z.ZodSchema.prototype, and in
zod 4 ZodSchema is a type with no value behind it. Under zod 4 the call
throws "Cannot read properties of undefined (reading 'prototype')" when
schema.ts loads — and schema.ts loads with the contract, the
implementation and the OpenAPI document, which the app's server router
imports, so since the version sweep that router could not load. The types
said as much: `openapi` does not exist on a zod 4 schema.

The extension goes. The four descriptions and the two lists of allowed time
zones and date formats move to .meta(), which zod 4 keeps in its registry;
the two empty .openapi({}) calls carried nothing and go. No parse changes.

Not fixed here: @ts-rest/open-api 3.52 still converts schemas through
anatine, which reads zod 3's internals, so /api/v1/openapi.json now loads
but every schema in it is empty. Before the sweep it had them.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:47:49 -07:00
zeekayandHanzo Dev fa58fe3519 fix(dialogs): give the delete confirmations their message back
Six delete dialogs ask for a name typed exactly, as z.literal(name, {
errorMap: () => ({ message }) }). zod 4 took errorMap out of a schema's
params — it is `error` now — and ignores what it does not know, so since
the version sweep a wrong entry has shown zod's default, `Invalid input:
expected "delete acme"`, instead of "You must enter 'delete acme' to
proceed". The types caught it: the object literal no longer matched, and the
failed overload left the field's value typed as any literal, which the
input would not take.

The six build the field with createConfirmationSchema(name, message), which
spells it the zod 4 way. confirmation.test.ts checks the exact text passes
and near misses fail with the given message; with the errorMap spelling put
back, the second case fails on zod's default. zod 3 kept the message, so
this is the behaviour from before the sweep.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:38:33 -07:00
zeekayandHanzo Dev 3182cc6b95 fix(analytics): import the Insights client by the name it exports
The version sweep resolved @hanzo/insights to 1.358.1, which exports its
client as `insights` and as the default, and no longer as `posthog`. The
analytics hook imported `posthog`, so the binding was undefined — a link
error for the ESM build, and a TypeError on the first capture wherever the
module did load.

The hook imports `insights`. use-analytics.test.ts sets an Insights key,
records what reaches the client and checks an event and an error both get
there, and that nothing does without a key; against the old import the
first case fails on the undefined client.

turbo.json still declared the key under its pre-rename name,
NEXT_PUBLIC_POSTHOG_KEY, which nothing reads; it declares
NEXT_PUBLIC_INSIGHTS_KEY, which the feature flags read and the test sets.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:36:57 -07:00
zeekayandHanzo Dev f5af7d38b9 fix(react): type refs and JSX the way React 19's types do
@types/react went from 18 to 19 with React. Three of its changes reached
this code, none of them at run time:

- useRef(null) is a RefObject<T | null>, so the PDF viewer's constraint ref
  and the signature pad's validity check take one. The virtual list the
  first is handed to already did, and the check already returns false when
  current is null.
- useRef takes its initial value; the autosave timer and the signing card's
  timeout pass undefined, which is what useRef() gave them.
- The global JSX namespace is gone; the role icons import JSX from react.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:35:04 -07:00
zeekayandHanzo Dev d15022d194 fix(pdf): tear a pdf.js document down through its loading task
pdfjs-dist went from 5 to 6 and PDFDocumentProxy lost destroy(). In 5 it
was one line, `return this.loadingTask.destroy()`, so the task is what
there is to destroy. Three calls were left:

- pdfToImages called it after rendering. The call threw before its .catch
  could attach, so every render ended in "pdf.destroy is not a function"
  and the AI features that read a document got nothing. The task it also
  destroyed is the same task, so that call is the one kept.
- The PDF viewer called it when a new document replaced an old one, inside
  the load's try, so switching documents showed the load error; and in the
  effect's cleanup. Both now destroy the loading task, as 5 did for them.

pdf-to-images.test.ts renders a two-page PDF and checks each page comes
back as a letter-size JPEG in order; against the old code it fails with the
TypeError.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:31:23 -07:00
zeekayandHanzo Dev d3e5ba335f fix(jobs): define Inngest functions the way inngest 4 takes them
inngest went from 3 to 4 in the version sweep. 4's createFunction takes the
triggers inside its options and the handler second, and throws when handed
the old three arguments. With the Inngest provider selected, the first
defineJob threw "createFunction expected a handler function as the second
argument", so no job could be registered. The trigger now sits in the
options.

Starting a job as a step of another sent `{ ...options, timestamp }`, whose
payload was not the event's `data`, so the job it started parsed an empty
event. inngest 3's types let that through and 4's refuse it. Both ways of
starting a job now send toInngestEvent(options): the payload as `data`, the
timestamp as `ts`, as triggerJob already did. inngest.test.ts holds the
mapping; a run of defineJob against the installed inngest registers each
job with its event or cron trigger and a handler.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:29:47 -07:00
zeekayandHanzo Dev f29acbe6ae fix(email): read the MailChannels sender through the address helper
@types/nodemailer 8 types a message's `from` as one address or a list, as
nodemailer has always accepted. The transport only knew a string or one
Address, so the list case did not type-check and, at run time, posted a
sender with no email.

The sender now goes through toMailChannelsAddresses, which already handles
all three shapes for to, cc and bcc, and the first address is the sender. A
string or a single Address comes out exactly as before.
mailchannels.test.ts sends each shape through nodemailer and reads what was
posted; against the old code the list case fails with `{}`.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:27:20 -07:00
zeekayandHanzo Dev 715cf40886 fix(crypto): take managedNonce from where noble 2 keeps it
@noble/ciphers 2 moved managedNonce from webcrypto.js to utils.js. The
import left behind named an export that no longer exists, so crypto.ts —
and everything that stores or reads an encrypted secret through it — could
not load: in ESM a missing named export fails the module at link time.

crypto.test.ts round-trips a secret, checks each write draws a new nonce and
that another key is refused, and decrypts a secret written by
@noble/ciphers 0.6.0 through this module's old code, since those are what
the database holds. Against the old import all four fail.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:26:16 -07:00
zeekayandHanzo Dev 64c558cdc4 fix(settings): validate the default date format as a date format
The document preferences form validated its date format with
ZDocumentMetaTimezoneSchema, a bare string, while its type and the settings
routes it submits to say ZDocumentMetaDateFormatSchema, the list of formats.
@hookform/resolvers 3 never compared the two; 5 types the submit handler by
what the schema returns, and a string is not a date format.

The field now validates with the date format schema. The select only offers
DATE_FORMATS and the server has rejected anything else since the routes
were written, so a value the form sends is never newly refused; what
changes is that a bad value stops in the form instead of at the server.
date-formats.test.ts holds that: every format the select offers and the
stored default are in the list the schema is made of, and a time zone is
not.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:23:43 -07:00
zeekayandHanzo Dev b2230be7f9 fix(editor): keep the editor's fields in a form without a resolver
useEditorFields keeps the envelope editor's fields in a react-hook-form
field array. Under @hookform/resolvers 5 its zod resolver types the form as
the schema's input, where field meta may be `{}`, while every function the
hook returns takes and gives parsed fields. Spelled as the output, the
resolver does not fit the form; left to the resolver, the fields do not fit
the functions.

The resolver never ran. The form is private to the hook, which only reads
it with getValues, rewrites it with reset and edits it through the field
array. Nothing calls handleSubmit or trigger or reads formState; with the
default onSubmit mode a field array validates only after a submit, and
validity is computed only when something subscribes to isValid. So the form
drops the resolver and is typed as what it holds: parsed fields, which is
what the defaults and every write already put in it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:23:34 -07:00
zeekayandHanzo Dev 252e15250f fix(forms): type the app's other forms by their schema's input
The same @hookform/resolvers 5 change, in the dialogs, settings forms, the
envelope editor and embedded authoring: `useForm<T>` pinned a form to its
schema's output, the resolver no longer matches that, so each form lets the
resolver type it, and whatever takes its control or context says input and
output — Control<Input, unknown, Output>, UseFormReturn likewise.

What the input exposed:
- zod 4 types z.preprocess's input as unknown unless the function names it.
  The distribute dialog's and the settings dialog's emailReplyTo hold a
  string or nothing, and now say so, as the two flow steps do.
- Combobox and ColorPicker already render an unset value as their
  placeholder and default; their props now allow one.
- The embed's signature types and the editor's signers' actionAuth may be
  unset in the form. The multiselect reads `?? []` and the signer check
  `?? []`, the schemas' own defaults; every writer sets both, so nothing
  reachable changes.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:21:49 -07:00
zeekayandHanzo Dev 04b66be987 fix(forms): type the document and template flows by the form's input
@hookform/resolvers 5 types a zod resolver as input in, output out: the
form holds z.input of its schema, and only handleSubmit sees z.output. The
flows spelled their forms as the output and dropped the input on the floor,
where a default or a transform makes the two differ: signers' actionAuth and
distributionMethod have defaults, and field meta takes `{}` and makes it
undefined.

Each flow's schema now exports its input beside its output, and whatever
holds or passes on the form's values uses it: the clipboard and last-active
field, the field item, its content and tooltip (which re-parse the meta
anyway), the email checkboxes, and every autosave. Autosave reads
getValues(), which is what the user typed, not what the schema made of it,
so it is typed as that all the way to the mutation, whose request type is
now z.input of the request schema — what the server parses.

Three reads of form state now apply the schema's own default where the
value may be unset: `actionAuth ?? []` in both signer steps and
`distributionMethod ?? EMAIL` in the subject step. Every writer in those
forms sets the value, so nothing reachable changes. Two `value &&
onChange(value)` statements become ifs, which typescript-eslint 8 asks for.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:20:14 -07:00
zeekayandHanzo Dev 5938f042a2 fix(editor): bind the shared field inputs through the form they render in
The field editor's shared inputs — font size, alignment, line height, label,
required, read-only — took a `Control<any>` so one component could serve
every field form. react-hook-form 7.88 types a control's `validate` option
by the form's field names, and a function over "fontSize" | "textAlign" is
not a function over any string, so no form's control was a `Control<any>`
any more: 26 errors, one per shared input per form.

The inputs now read the form from the <Form> they render inside, as
Controller does when it is not handed a control. Every one of them already
did through FormMessage, and every form renders them inside its own <Form>,
so the control each one binds to is the one it was handed before. The
`Control<any>`, its eslint-disable and the formControl prop go.

Two forms spelled their values as the schema's output, `useForm<T>`. Under
@hookform/resolvers 5 a zod resolver takes the schema's input and returns
its output, so they now let the resolver type them. zod 4 types a coerced
number's input as unknown; the field meta and the checkbox form say number,
the type these forms were written against, which changes no parse.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:19:22 -07:00
zeekayandHanzo Dev fe36557515 fix(ui): let FormField carry a form's transformed values
react-hook-form 7.55 gave a form a third type, the values its resolver
hands to submit, and Controller's props carry it. FormField still took two
and passed ControllerProps<Values, Name>, which fixes the third to Values.
Any form whose resolver returns anything else could not hand its control to
a FormField: 97 errors in the app's typecheck and 42 in packages/ui, most of
them one per field of every such form.

FormField takes the third parameter and passes it on, as Controller does.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-19 04:18:42 -07:00