/** * Request-response RPC route for `@shade/files`. * * Mounts a single `POST /rpc` Hono endpoint that accepts an encrypted * `RpcRequest` envelope, dispatches it through the file handler, and * returns the encrypted `RpcResponse` (or `RpcError`) envelope in the * SAME HTTP response. * * This is the browser-friendly transport: the server never needs to * make outbound calls back to the client, so a browser tab — which * cannot host an HTTP server — can fully consume `@shade/files`. * * ### Wire contract * * Request: * ``` * POST /rpc HTTP/1.1 * Content-Type: application/octet-stream * X-Shade-Sender-Address: * * * ``` * * Response (success): * ``` * 200 OK * Content-Type: application/octet-stream * * * ``` * * Response (transport-level failure — no session, undecryptable, etc.): * ``` * 4xx * Content-Type: application/json * * { "error": "..." } * ``` * * ### Symmetry with shade-auth-middleware * * The shape mirrors `@shade/server`'s shade-auth-middleware: an * encrypted envelope rides the request body, the server decrypts via * the existing ratchet session, performs the protected operation, * and returns an encrypted envelope in the response. No bidirectional * channel required. * * @see {@link createFilesHttpClient} for the matching browser client. */ import { Hono } from 'hono'; import { decodeEnvelope, encodeEnvelope } from '@shade/proto'; import type { ShadeBridge } from '../integration/shade-bridge.js'; import { encodeEnvelope as encodeRpcEnvelope, tryParseEnvelope, } from '../protocol/envelope-codec.js'; import type { FileHandler } from './handler.js'; import type { RpcError, RpcRequest, RpcResponse } from '../schemas/envelope.js'; import { KIND_ERROR_V1 } from '../protocol/kinds.js'; export interface FilesRpcRouteOptions { /** * Maximum request body size in bytes. Default 1 MiB. Inline payloads * are capped at 256 KiB by the protocol; the headroom is for * custom-op payloads and base64 inflation. */ maxBodyBytes?: number; /** * Allow this server to accept the very first message (PreKeyMessage, * `0x01`) over the RPC route. Disabled by default — most browser * clients establish a session via `shade.initSessionFromBundle` * before the first RPC. Enable when you want the RPC route to also * be the X3DH carrier (uncommon but supported). */ acceptFirstMessage?: boolean; } const DEFAULT_MAX_BODY_BYTES = 1 * 1024 * 1024; /** * Build a Hono app with a single `POST /rpc` route. Mount under any * base path: `app.route('/api/v1/shade-files', shade.files.rpcRoute())`. * * The `handler` must already be attached (typically via * `shade.files.serve(handlerConfig)`); this route only ships the * transport — it does not register a new file handler. */ export function createFilesRpcRoute( shade: ShadeBridge, handler: FileHandler, options: FilesRpcRouteOptions = {}, ): Hono { const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; const app = new Hono(); app.post('/rpc', async (c) => { const senderAddress = c.req.header('X-Shade-Sender-Address'); if (senderAddress === undefined || senderAddress === '') { return c.json({ error: 'missing X-Shade-Sender-Address header' }, 400); } const contentLengthHeader = c.req.header('Content-Length'); if (contentLengthHeader !== undefined) { const contentLength = Number.parseInt(contentLengthHeader, 10); if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) { return c.json( { error: `body exceeds maxBodyBytes (${contentLength} > ${maxBodyBytes})` }, 413, ); } } let bodyBytes: Uint8Array; try { const ab = await c.req.arrayBuffer(); if (ab.byteLength > maxBodyBytes) { return c.json( { error: `body exceeds maxBodyBytes (${ab.byteLength} > ${maxBodyBytes})` }, 413, ); } bodyBytes = new Uint8Array(ab); } catch (err) { return c.json({ error: `failed to read request body: ${(err as Error).message}` }, 400); } if (bodyBytes.byteLength === 0) { return c.json({ error: 'empty request body' }, 400); } // Decode the wire envelope. `decodeEnvelope` handles both `0x01` // PreKeyMessage and `0x02` RatchetMessage shapes. let plaintext: string; try { const envelope = decodeEnvelope(bodyBytes); // First-message gate: only allow `prekey` envelopes when the // operator has explicitly opted in. if (options.acceptFirstMessage !== true && envelope.type === 'prekey') { return c.json( { error: 'PreKeyMessage envelopes are not accepted on this RPC route — establish the session first via shade.initSessionFromBundle, or set acceptFirstMessage: true', }, 400, ); } plaintext = await shade.receive(senderAddress, envelope); } catch (err) { // Decryption failure — could be no session, corrupted envelope, // or sender address mismatch. Treat as 401 since the envelope is // self-authenticating: a valid sender would decrypt cleanly. return c.json({ error: `decrypt failed: ${(err as Error).message}` }, 401); } // Parse the plaintext as an RpcRequest. const classified = tryParseEnvelope(plaintext); if (classified === null) { return c.json({ error: 'plaintext is not a valid @shade/files envelope' }, 400); } if (classified.kind !== 'request') { // Cancel envelopes are silently dropped — RPC route is request/ // response only. Cancellation across HTTP is achieved via // AbortController on the client side, not protocol-level. if (classified.kind === 'cancel') { handler.handleCancel(senderAddress, classified.envelope); // No response body — the cancel was best-effort. return new Response(null, { status: 204 }); } return c.json( { error: `unexpected envelope kind on RPC route: ${classified.kind}` }, 400, ); } const request: RpcRequest = classified.envelope; // Dispatch through the file handler. let result: RpcResponse | RpcError; try { result = await handler.handleRequest(senderAddress, request); } catch (err) { // Should never happen — handler.handleRequest catches its own // errors and returns RpcError. If it didn't, that's a bug; emit // a generic transport-level RpcError so the client can surface // it deterministically. result = { kind: KIND_ERROR_V1, id: request.id, error: { code: 'INTERNAL', message: `handler raised: ${(err as Error).message}`, }, }; } // Encrypt the response and return it as wire bytes. let responseBytes: Uint8Array; try { const responsePlaintext = encodeRpcEnvelope(result); const responseEnvelope = await shade.send(senderAddress, responsePlaintext); responseBytes = encodeEnvelope(responseEnvelope); } catch (err) { return c.json( { error: `failed to encrypt response: ${(err as Error).message}` }, 500, ); } return new Response(new Blob([responseBytes as unknown as ArrayBuffer]), { status: 200, headers: { 'Content-Type': 'application/octet-stream' }, }); }); return app; }