@writhq/verify
v0.2.1
Published
Writ platform middleware — requireKYA() for Hono / Express / Next, plus a Verify API client. ~10 lines to gate an endpoint on a verified agent passport. Fails closed.
Downloads
635
Maintainers
Readme
@writhq/verify
Writ — KYA (Know Your Agent). Gate any endpoint on a verified agent passport.
The platform-side middleware. Add ~10 lines and an endpoint accepts requests
only from agents presenting a valid, in-scope, in-cap, non-revoked passport.
It fails closed: a missing header, a denied decision, an over-cap amount, or
an unreachable passport all block. Blocked callers get a 403 "KYA required"
block body with an onboarding link — rejected traffic becomes leads.
Adapters ship for Hono, Express, and Next.js (App Router). The
agent side is @writhq/sdk.
Install
npm install @writhq/verifyRequires Node 20+. Zero runtime dependencies. ESM only — there is no
CommonJS build, so require('@writhq/verify') fails with
ERR_PACKAGE_PATH_NOT_EXPORTED. That error reads like a broken package and is
not: use import, or await import() from CJS.
Configuration
Both are read from the environment (or pass them per-call):
PASSPORT_URL— base URL of the passport service (defaulthttps://api.writhq.com; set it to point at a local stack).PLATFORM_API_KEY— your platform's Bearer key forPOST /v1/verify(NORTHBANK_API_KEYis also accepted as a fallback).
Hono
import { Hono } from 'hono';
import { requireKYA } from '@writhq/verify';
const app = new Hono();
app.post(
'/api/refill',
requireKYA({
action: 'account.refill',
amount: async (c) => (await c.req.json()).amount_minor, // MINOR units
}),
async (c) => {
const kya = c.get('kya'); // resolved verify response (chain, receipt, ...)
return c.json({ ok: true, chain: kya.chain });
},
);Express
import express from 'express';
import { requireKYAExpress } from '@writhq/verify';
const app = express();
app.use(express.json());
app.post(
'/api/refill',
requireKYAExpress({ action: 'account.refill', amount: (req) => req.body.amount_minor }),
(req, res) => {
res.json({ ok: true, chain: req.kya.chain }); // req.kya set on allow
},
);Next.js (App Router)
import { guardKYA } from '@writhq/verify';
export async function POST(req: Request) {
const gate = await guardKYA(req, {
action: 'account.refill',
amount: async () => (await req.clone().json()).amount_minor,
});
if (gate instanceof Response) return gate; // 403 block page / denial
// gate is the verify response — gate.chain, gate.receipt, ...
return Response.json({ ok: true, chain: gate.chain });
}The block body
Blocked callers receive (HTTP 403, or the passport's status on a denial):
{
"error": "KYA required",
"message": "This action requires a verified agent passport.",
"reason": "missing_passport",
"onboarding_url": "https://.../onboarding"
}Set onboardingUrl in the options to control where leads land.
Signature authority (document.sign)
Gate a signing route the way you gate a payment route. A document resolver
replaces amount, because a signing call states its number once — as
liability_minor:
app.post('/api/sign',
requireKYA({
action: 'document.sign',
// hash · class · counterparty · liability, read from the REAL request
document: async (c) => (await c.req.json()).document,
}),
handler, // only runs when the agent had the authority; fails closed
);// the document block — the same object the agent signed for
{
"document_hash": "<lowercase hex sha256 of the document bytes>",
"document_class": "nda", // nda | msa | sow | order_form | dpa | other
"counterparty": "Northbank Sandbox",
"liability_minor": 2500000 // $25,000 of exposure
}The mandate names which classes the agent may sign and reads its caps as
liability caps: max_amount_per_tx per document, max_amount_per_period
cumulative. Three ways it stops — document_class (wrong kind of paper),
per_tx_cap (one document too big), period_cap (too much cumulative
exposure). Present a document that differs from the one the agent signed for and
the decision is context_mismatch. The passport never receives the document,
only its SHA-256.
Writ attests the authority. It never produces the signature — your e-signature step still does that — and none of this is a qualified electronic signature (eIDAS/QES).
Countersigning
After you execute the action, co-sign the verification. A record signed by both sides — Writ's receipt plus your platform key — is the strongest artifact this system produces.
import { countersign, generateKeypair, publicFromPrivate } from '@writhq/verify';
// Once, at setup: mint a countersigning keypair and register the PUBLIC half
// with POST /v1/platforms/self/key using your platform API key.
const { privateJwk, publicJwk } = await generateKeypair();
// Then, after every executed action:
const jws = await countersign(
{
verification_id,
decision: 'allow',
platform: 'plt_your_platform',
platform_ref: ledgerTxId, // your own id for what you just did
ts: new Date().toISOString(),
},
privateJwk,
);
await fetch(`${passportUrl}/v1/verifications/${verification_id}/countersign`, {
method: 'POST',
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
body: JSON.stringify({ countersig: jws }),
});If a countersignature is rejected 422, the response names the public key
actually on file for your platform so you can compare it against the key you
signed with — that mismatch is the only way a correct integration fails here.
Lower level
KYAClient— thin, never-throws client forPOST /v1/verify. Any transport or protocol failure resolves to{ ok: false }so you fail closed.evaluateRequest(ctx, opts, headerGetter)— the framework-agnostic core the adapters wrap; use it to build your own adapter.blockBody,PASSPORT_HEADER("x-passport").
import { KYAClient } from '@writhq/verify';
const client = new KYAClient({ passportUrl: process.env.PASSPORT_URL, apiKey: process.env.PLATFORM_API_KEY });
const outcome = await client.verify({ assertion, action: 'account.refill', amount: 50_000 });
if (!outcome.ok) return deny(outcome.reason); // 'per_tx_cap' | 'mandate_revoked' | ...API
requireKYA (Hono) · requireKYAExpress (Express) · guardKYA (Next) ·
evaluateRequest · KYAClient · blockBody · PASSPORT_HEADER. Types:
RequireKYAOptions (incl. the document resolver), KYABlockBody,
KYAContext, KYAClientConfig, KYAOutcome, VerifyArgs, VerifyResponse.
Home: https://writhq.com · Docs: https://writhq.com/docs/ · API: https://api.writhq.com
License
MIT © Tundra Industries
