release(v4.1.0): browser-friendly HTTP RPC for @shade/files
Some checks failed
Test / test (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

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>
This commit is contained in:
2026-05-03 22:08:14 +02:00
parent 0bdf9e859c
commit da93b97cce
33 changed files with 1405 additions and 30 deletions

View File

@@ -0,0 +1,218 @@
/**
* 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;
}