mirror of
https://github.com/hanzoai/sign.git
synced 2026-10-21 01:21:51 +00:00
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.
129 lines
4.7 KiB
TypeScript
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 };
|