Default shade.files.client(peer) requires both peers to be mutually
addressable over HTTP — the response round-trips through
Shade.deliverControlEnvelope (POST to peer's /v1/transfer/control).
Browser tabs can't host an HTTP server, so they couldn't consume
@shade/files at all. Dispatch's filutforsker (admin-panel browser UI)
is the canonical use-case.
This release adds a parallel request-response transport: one POST per
RPC, encrypted envelope in the body, encrypted response in the same
HTTP response. No inbound channel needed on the client.
### New API
- shade.files.rpcRoute(opts?) — Hono app exposing POST /rpc.
- shade.files.httpClient(peer, opts) — request-response FileClient.
- FilesNamespace.serve(handler, { inlineOnly: true }) — skip streams-
bridge (and its configureTransfers pre-condition); also skip
channel-based dispatch so requests aren't double-dispatched.
### Limitations (v1)
Inline only (≤ 256 KiB). Streamed reads/writes throw clear errors
directing to shade.files.client(peer) on a server-to-server deploy.
### Tests
7 integration tests in tests/integration/http-rpc.test.ts covering
round-trip + negative cases (sender header, empty/garbage body,
maxBodyBytes, rpcRoute-without-serve).
### Symmetry
Mirrors @shade/server's shade-auth-middleware: encrypted envelope in
request body, decrypted via existing ratchet, response in same HTTP
roundtrip. No WebSocket, no SSE, no outbound from server.
Wire-compatible. Source-compatible. Lockstep bump to 4.1.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
7.5 KiB
TypeScript
219 lines
7.5 KiB
TypeScript
/**
|
|
* 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 <mount>/rpc HTTP/1.1
|
|
* Content-Type: application/octet-stream
|
|
* X-Shade-Sender-Address: <peer address>
|
|
*
|
|
* <wire-encoded ShadeEnvelope (0x01 PreKeyMessage or 0x02 RatchetMessage)
|
|
* containing JSON-encoded RpcRequest>
|
|
* ```
|
|
*
|
|
* Response (success):
|
|
* ```
|
|
* 200 OK
|
|
* Content-Type: application/octet-stream
|
|
*
|
|
* <wire-encoded ShadeEnvelope (0x02 RatchetMessage)
|
|
* containing JSON-encoded RpcResponse | RpcError>
|
|
* ```
|
|
*
|
|
* 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;
|
|
}
|