Files
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

98 lines
4.0 KiB
TypeScript

// esign ZAP RPC — JSON-over-HTTP face for the /v1/rpc operation API.
//
// The operation API (external integrators) was backed by
// trpc-to-openapi routing HTTP -> the tRPC appRouter. This re-backs it with
// `httpServe` from @zap-proto/web/server, dispatching every request through the
// SAME zapRoutes map + makeDispatcher the WebSocket server uses — one service
// implementation, reached two ways (WS and HTTP). NO tRPC.
//
// Each OpenAPI operation becomes one POST HttpRoute whose codec mirrors the
// browser ZAP client (zap/client): the JSON body is wrapped into a ZapRequest
// envelope carrying the route string (the operationId) as `method`, the payload
// as a SuperJSON string, under the single METHOD_RPC ordinal; the ZapReply is
// unwrapped back to JSON (or the reconstructed AppError is thrown).
//
// Auth is per-request via makeMintCap('apiV2'), which reads
// "Authorization: Bearer <token>" off the IncomingMessage and resolves it via
// getBearer — identical to the tRPC apiV2 context. httpServe hands the
// mint the raw upgrade/request auth boundary, so no second auth path exists.
import type { Server as HttpServer } from 'node:http';
import { type HttpRoute, type HttpServeHandle, httpServe } from '@zap-proto/web/server';
import SuperJSON from 'superjson';
import { makeDispatcher } from '@hanzo/esign-trpc/zap/server/dispatch';
import { fromWireError } from '@hanzo/esign-trpc/zap/runtime/error';
import { METHOD_RPC } from '@hanzo/esign-trpc/zap/runtime/method';
import { makeMintCap } from '@hanzo/esign-trpc/zap/server/mint';
import { publicRoutes, zapRoutes } from '@hanzo/esign-trpc/zap/server/routes';
import { ZapReply, newZapRequest } from '@hanzo/esign-trpc/zap/gen/transport_zap';
import openapi from '@hanzo/esign-trpc/zap/gen/openapi.json';
// Re-export the WebSocket ZAP server (serveZap) through this module so the
// raw-copied server/main.js imports BOTH serveZap and serveZapHttpApi from the
// single rollup-bundled ./hono/server/zap/http-api.js — keeping the first-party
// esign ZAP server layer fully bundled (it can't resolve as a .ts workspace
// subpath at runtime). @zap-proto/web stays an external registry dep.
export { serveZap } from '@hanzo/esign-trpc/zap/server';
interface OpenApiPaths {
paths: Record<string, { post?: { operationId?: string } }>;
}
/**
* Build one HttpRoute per `(path, post.operationId)` in the composite OpenAPI
* doc. The route string carried on the envelope is the operationId
* ("<router>.<procedure>"), which is exactly the key zapRoutes is registered
* under — so the HTTP face and the WS face resolve to the identical handler.
*/
function buildRoutes(): HttpRoute[] {
const { paths } = openapi as OpenApiPaths;
const routes: HttpRoute[] = [];
for (const [path, item] of Object.entries(paths)) {
const operationId = item.post?.operationId;
if (!operationId) continue;
routes.push({
path,
method: METHOD_RPC,
decode: (json: unknown): Uint8Array =>
newZapRequest({
method: operationId,
payload: json === undefined || json === null ? '' : SuperJSON.stringify(json),
teamId: '',
}),
encode: (body: Uint8Array): unknown => {
const reply = ZapReply.wrap(body);
if (!reply.ok) {
throw fromWireError(reply.status, reply.errorJson);
}
return reply.result === '' ? undefined : SuperJSON.parse(reply.result);
},
});
}
return routes;
}
/**
* Mount the JSON-over-HTTP ZAP face on `httpServer` at `/v1/rpc`, sharing the
* WS server's port. httpServe only terminates its declared POST routes and
* passes every other request through, so the Hono GET `/v1/rpc/openapi.json`
* and the GET download routes still reach Hono.
*/
export function serveZapHttpApi(
httpServer: HttpServer,
opts: { onError?: (err: unknown) => void } = {},
): HttpServeHandle {
return httpServe(httpServer, {
prefix: '/v1/rpc',
routes: buildRoutes(),
mintCap: makeMintCap('apiV2'),
rootCap: makeDispatcher(zapRoutes, publicRoutes),
onError: opts.onError,
});
}