Files
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

129 lines
4.7 KiB
TypeScript

// esign ZAP RPC — the server-side dispatcher.
//
// One CallHandler routes every esign procedure. It decodes the ZapRequest
// envelope, looks up the handler registered for the route ("<router>.<proc>"),
// runs it with the per-connection ZapContext (applying any per-call teamId
// override carried on the envelope, the ZAP analogue of tRPC's x-team-id
// header), and encodes the result — or the mapped AppError — into a ZapReply.
//
// Payloads ride as SuperJSON strings, the exact transformer tRPC used, so the
// typed-object semantics every callsite depends on survive the transport swap.
// Files ride as bytes alongside them and are rebuilt here (../runtime/file), so
// an upload route parses the same FormData it received over HTTP. The wire
// itself is binary ZAP (ZapRequest/ZapReply structs over WS frames), not
// JSON-RPC.
import type { CallHandler } from '@zap-proto/web';
import type { Call, Response } from '@zap-proto/zap';
import { Status } from '@zap-proto/zap';
import SuperJSON from 'superjson';
import { ZapReply, ZapRequest, newZapReply } from '../gen/transport_zap';
import { toWireError } from '../runtime/error';
import { unpack } from '../runtime/file';
import type { ZapConnectionContext, ZapContext } from './context';
/** A typed procedure handler: (ctx, input) → output. */
export type ZapHandler = (ctx: ZapContext, input: unknown) => Promise<unknown> | unknown;
/** route ("<router>.<procedure>") → handler. */
export type ZapRouteMap = Record<string, ZapHandler>;
const encoder = (value: unknown): string => (value === undefined ? '' : SuperJSON.stringify(value));
const decoder = (s: string): unknown => (s === '' ? undefined : unpack(SuperJSON.parse(s)));
/**
* Build the rootCap CallHandler over a route map. Unknown / unported routes
* resolve to a NOT_FOUND "not-yet-migrated" ZapReply so the build stays green
* while routers are ported incrementally.
*/
export function makeDispatcher(routes: ZapRouteMap, publicRoutes: ReadonlySet<string>) {
return (ctx: ZapConnectionContext): CallHandler => {
return async (call: Call): Promise<Response> => {
let req: ZapRequest;
try {
req = ZapRequest.wrap(call.payload);
} catch {
return reply(call.promiseID, {
ok: false,
status: Status.BadRequest,
result: '',
errorJson: JSON.stringify({
code: 'INVALID_BODY',
message: 'malformed ZAP request envelope',
}),
});
}
const route = req.method;
const handler = routes[route];
if (!handler) {
return reply(call.promiseID, {
ok: false,
status: Status.NotFound,
result: '',
errorJson: JSON.stringify({
code: 'NOT_FOUND',
message: `not-yet-migrated: ${route}`,
}),
});
}
// An anonymous connection reaches the public routes and nothing else.
if (ctx.user === null && !publicRoutes.has(route)) {
return reply(call.promiseID, {
ok: false,
status: Status.Unauthorized,
result: '',
errorJson: JSON.stringify({ code: 'UNAUTHORIZED', message: `sign in to call ${route}` }),
});
}
// Per-call teamId override (ZAP analogue of the x-team-id header), for a
// signed-in connection. A public handler takes ZapConnectionContext and
// reads no team, which routes.ts holds it to.
const perCallTeamId = req.teamId ? Number(req.teamId) : NaN;
const ctxForCall: ZapContext =
ctx.user === null
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(ctx as unknown as ZapContext)
: Number.isFinite(perCallTeamId) && perCallTeamId > 0
? { ...ctx, teamId: perCallTeamId }
: ctx;
try {
const input = decoder(req.payload);
const output = await handler(ctxForCall, input);
return reply(call.promiseID, {
ok: true,
status: Status.OK,
result: encoder(output),
errorJson: '',
});
} catch (err) {
const { status, errorJson } = toWireError(err);
return reply(call.promiseID, { ok: false, status, result: '', errorJson });
}
};
};
}
interface ReplyFields {
ok: boolean;
status: number;
result: string;
errorJson: string;
}
function reply(promiseID: number, fields: ReplyFields): Response {
const body = newZapReply(fields);
return { status: fields.status, promiseID, body };
}
/** Decode a ZapReply body on the client side. */
export function readReply(body: Uint8Array): ReplyFields {
const r = ZapReply.wrap(body);
return { ok: r.ok, status: r.status, result: r.result, errorJson: r.errorJson };
}
export const zapCodec = { encode: encoder, decode: decoder };