diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c140c..77e6f23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,103 @@ All notable changes to Shade are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.1.0] — 2026-05-03 — Browser-friendly HTTP RPC for @shade/files + +The default `shade.files.client(peer)` requires both peers to be +mutually addressable over HTTP — the response to a `list` / `read` / +etc. round-trips through `Shade.deliverControlEnvelope`, which POSTs +to the peer's `/v1/transfer/control` endpoint. **That doesn't work +for browsers** — a tab can't host an HTTP server, so the server +cannot call back outbound. + +This release ships a parallel request-response transport. One POST per +RPC, encrypted envelope in the request body, encrypted response in the +same HTTP response. Mirrors the way `@shade/server`'s +`shade-auth-middleware` works for prekey writes. + +### Added + +#### `@shade/files` +- `createFilesRpcRoute(shade, handler, options?)` — Hono app exposing + `POST /rpc`. Reads `X-Shade-Sender-Address`, decrypts the envelope + via the existing ratchet session, dispatches through the attached + `FileHandler`, encrypts the result, and returns it in the same HTTP + response. Transport-level failures (no session, undecryptable, body + too big) return JSON `{ error }` with appropriate 4xx; application- + level failures ship encrypted `RpcError` envelopes. +- `createFilesHttpClient(shade, peer, options)` — request-response + `FileClient` for browser-style consumers. Each method (list / stat / + mkdir / delete / move / getThumbnail / custom / write inline / read + inline) does one HTTP POST and parses the encrypted response. No + inbound channel required. +- `shade.files.rpcRoute(opts?)` — namespace-side getter for the route. + Throws if no handler has been attached via `shade.files.serve(...)` + first. +- `shade.files.httpClient(peer, opts)` — namespace-side getter for the + client. +- `FilesNamespace.serve(handler, { inlineOnly: true })` — opt-out flag + that skips the streams-bridge setup. Required for HTTP-RPC-only + servers (which don't need `configureTransfers({ resolveBaseUrl })`). + In `inlineOnly` mode the channel-based dispatcher is also not + attached, so requests are dispatched only by the rpc-route — avoids + double-dispatch when a browser client and a server-to-server client + share the same Shade instance. +- `ShadeBridge` (exported) gains a `receive(peer, envelope)` member + matching `Shade.receive` so server-side rpc-route can decrypt + inbound envelopes through the structural surface. + +### Wire contract + +``` +POST /rpc HTTP/1.1 +Content-Type: application/octet-stream +X-Shade-Sender-Address: alice@example.com + + + +──── + +200 OK +Content-Type: application/octet-stream + + +``` + +### Limitations (v1) + +- **Inline payloads only** (≤ 256 KiB). `write` of larger inputs + throws `ConflictError` directing callers to `shade.files.client(peer)` + on a server-to-server deployment. Streamed `read` results throw + `InternalFileError` for the same reason. +- The X3DH first-message must ride the same RPC route — set + `acceptFirstMessage: true` on `rpcRoute({ acceptFirstMessage: true })` + when the browser client's first-ever call doubles as the handshake. + +### Tests + +- `tests/integration/http-rpc.test.ts` — round-trip via HTTP + (list / mkdir / stat / write / read / delete) plus negative cases + (streamed write rejected, missing sender header, empty body, garbage + body, body past `maxBodyBytes`, `rpcRoute()` without `serve()`). + +### Migration + +`4.0.x → 4.1.0` is wire-compatible and source-compatible. The HTTP +RPC route is purely additive — no existing code path changes. To +adopt: + +```ts +// server (was) +await shade.files.serve(handlerConfig); + +// server (HTTP-RPC mode) +await shade.files.serve(handlerConfig, { inlineOnly: true }); +app.route('/api/v1/shade-files', shade.files.rpcRoute()); + +// browser client +const fs = shade.files.httpClient(serverAddress, { rpcUrl: '...' }); +``` + ## [4.0.2] — 2026-05-03 — Consumer-strict reader-shape fixes `4.0.1` shipped the `tsc --noEmit` gate that compiles each package diff --git a/docs/files.md b/docs/files.md index 389a1e0..5ec9fa5 100644 --- a/docs/files.md +++ b/docs/files.md @@ -53,6 +53,111 @@ if (result.kind === 'inline') console.log(result.bytes.byteLength); else for await (const _chunk of /* result.stream */) { /* ... */ } ``` +## HTTP RPC — browser-friendly request-response (4.1+) + +The default `shade.files.client(peer)` requires both peers to be mutually +addressable over HTTP — the response to a `list`/`read` etc. round-trips +through `Shade.deliverControlEnvelope`, which POSTs to the peer's +`/v1/transfer/control` endpoint. **That doesn't work for browsers** — +a tab can't host an HTTP server, so the server cannot call back outbound. + +`@shade/files` 4.1 ships a parallel **request-response** transport that +lets browser-style clients fully consume the file-RPC surface without +any inbound channel. It mirrors the way `@shade/server`'s +`shade-auth-middleware` works: one POST per RPC, encrypted envelope in +the request body, encrypted response in the same HTTP response. + +### Server side — mount the RPC route + +```ts +// 1. Register the file handler. `inlineOnly: true` skips the +// streams-bridge (which would require @shade/transfer). +await shade.files.serve(handlerConfig, { inlineOnly: true }); + +// 2. Mount the route on your Hono app under any base path. +app.route('/api/v1/shade-files', shade.files.rpcRoute()); +// ^^^^^^^^^^^^^^ +// POST /rpc +``` + +`rpcRoute()` accepts: + +| Option | Default | Purpose | +|---------------------|---------|----------------------------------------------------------------------------------------------------| +| `maxBodyBytes` | 1 MiB | Max request body. The protocol caps inline payloads at 256 KiB; the headroom is for base64 inflation + custom-op envelopes. | +| `acceptFirstMessage`| `false` | Accept `0x01` PreKeyMessage envelopes — required when the RPC route also doubles as the X3DH handshake (browser's first-ever request). | + +### Browser client + +```ts +import { createShade } from '@shade/sdk'; + +const shade = await createShade({ + prekeyServer: 'https://shade.example.com', + storage: 'memory', + address: 'alice@example.com', +}); + +const fs = shade.files.httpClient('bob@example.com', { + rpcUrl: 'https://dispatch.example.com/api/v1/shade-files/rpc', + // Optional: thread CSRF / auth tokens, override fetch, etc. + headers: { 'X-CSRF-Token': csrfToken }, +}); + +await fs.mkdir('/photos'); +await fs.write('/photos/cover.png', new Uint8Array([/* ... */]), { + contentType: 'image/png', +}); +const result = await fs.read('/photos/cover.png'); +``` + +### What works in HTTP-RPC mode + +- `list`, `stat`, `mkdir`, `delete`, `move`, `getThumbnail`, `custom` — full parity. +- `write` — **inline only** (≤ 256 KiB plaintext). Larger inputs throw `ConflictError`. +- `read` — **inline only**. If the server returns a streamed `read` result, the client throws `InternalFileError` directing callers to the stateful pathway. + +### Wire contract + +``` +POST /rpc HTTP/1.1 +Content-Type: application/octet-stream +X-Shade-Sender-Address: alice@example.com + + + +──── + +200 OK +Content-Type: application/octet-stream + + +``` + +Transport-level failures (no session, undecryptable envelope, body too +big) return JSON `{ "error": "..." }` with appropriate 4xx status. +Application-level failures (file not found, permission denied) ship +encrypted `RpcError` envelopes — the client maps them back to typed +`FileError` subclasses (`NotFoundError`, `ConflictError`, etc.). + +### Symmetry with `@shade/server` + +The shape mirrors `@shade/server`'s shade-auth-middleware: encrypted +envelope rides the request body, server decrypts via the existing +ratchet session, performs the protected operation, returns an encrypted +envelope in the response. No bidirectional channel required, no +WebSocket, no SSE. + +### When to use which + +| Setup | Use | +|-----------------------------------------------|-----------------------------------------------| +| Browser client ↔ Bun/Hono server | `httpClient()` + `rpcRoute()` | +| Server ↔ server (both can host HTTP) | `client()` (default) — supports streams | +| Service-worker / extension ↔ server | `httpClient()` (no inbound listener) | +| CLI / daemon ↔ daemon | Either; `client()` if you need streams | + ## Op surface | Op | Args | Result | diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json index 9931e6a..24d0946 100644 --- a/packages/shade-cli/package.json +++ b/packages/shade-cli/package.json @@ -1,6 +1,6 @@ { "name": "@shade/cli", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/cli.ts", "bin": { diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json index 7c193ed..65f95d0 100644 --- a/packages/shade-core/package.json +++ b/packages/shade-core/package.json @@ -1,6 +1,6 @@ { "name": "@shade/core", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-crypto-web/package.json b/packages/shade-crypto-web/package.json index 935efd0..ba781a3 100644 --- a/packages/shade-crypto-web/package.json +++ b/packages/shade-crypto-web/package.json @@ -1,6 +1,6 @@ { "name": "@shade/crypto-web", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-dashboard/package.json b/packages/shade-dashboard/package.json index f528669..6bca262 100644 --- a/packages/shade-dashboard/package.json +++ b/packages/shade-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@shade/dashboard", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shade-files/package.json b/packages/shade-files/package.json index 948eda2..fa9e498 100644 --- a/packages/shade-files/package.json +++ b/packages/shade-files/package.json @@ -1,6 +1,6 @@ { "name": "@shade/files", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-files/src/client/http-client.ts b/packages/shade-files/src/client/http-client.ts new file mode 100644 index 0000000..5755caf --- /dev/null +++ b/packages/shade-files/src/client/http-client.ts @@ -0,0 +1,399 @@ +/** + * Browser-friendly request-response `FileClient` for `@shade/files`. + * + * The default `shade.files.client(peer)` ships RPC envelopes via + * `Shade.send` + `Shade.deliverControlEnvelope`, which means the + * server has to be able to call back outbound to the client. That + * doesn't work for browser tabs (no inbound HTTP listener). This + * client posts each RPC envelope to a single server endpoint and + * reads the encrypted response from the same HTTP response — pure + * request-response, no inbound channel required. + * + * Inline payloads only (≤ 256 KiB). For larger reads/writes, use the + * stateful path: `shade.files.client(peer)` server-to-server, with + * `@shade/transfer` chunk routes for content I/O. + * + * @see {@link createFilesRpcRoute} for the matching server-side route. + */ +import type { ZodTypeAny } from 'zod'; +import { decodeEnvelope, encodeEnvelope as encodeWireEnvelope } from '@shade/proto'; +import type { ShadeBridge } from '../integration/shade-bridge.js'; +import { + encodeEnvelope as encodeRpcEnvelope, + tryParseEnvelope, +} from '../protocol/envelope-codec.js'; +import { + KIND_CUSTOM_V1, + KIND_DELETE_V1, + KIND_GET_THUMBNAIL_V1, + KIND_LIST_V1, + KIND_MKDIR_V1, + KIND_MOVE_V1, + KIND_READ_V1, + KIND_STAT_V1, + KIND_WRITE_V1, +} from '../protocol/kinds.js'; +import { + CustomArgsSchema, + CustomResultSchema, + DeleteArgsSchema, + DeleteResultSchema, + GetThumbnailArgsSchema, + GetThumbnailResultSchema, + ListArgsSchema, + ListResultSchema, + MkdirArgsSchema, + MkdirResultSchema, + MoveArgsSchema, + MoveResultSchema, + ReadArgsSchema, + ReadResultSchema, + StatArgsSchema, + StatResultSchema, + WriteArgsSchema, + WriteResultSchema, + type ListResult, + type MkdirResult, + type DeleteResult, + type MoveResult, + type StatResult, + type ThumbnailSize, + type WriteResult, +} from '../schemas/ops.js'; +import { + fileErrorFromPayload, + CancelledError, + InternalFileError, + ConflictError, +} from '../schemas/errors.js'; +import { buildRpcRequest } from '../protocol/rpc-builder.js'; +import { decideInline, INLINE_THRESHOLD, type WriteSource } from './inline-threshold.js'; +import { base64ToBytes, bytesToBase64 } from '../protocol/canonical.js'; +import type { + FileClient, + ReadOpts, + ReadOutput, + ThumbnailResult, + WriteOpts, + CreateFileClientOptions, + BaseOpts, +} from './client.js'; + +export interface FilesHttpClientOptions + extends Omit { + /** + * Server endpoint that hosts `createFilesRpcRoute(...)`. Typically: + * `https://server.example.com/api/v1/shade-files/rpc`. + */ + rpcUrl: string; + /** + * Optional `fetch` override. Defaults to `globalThis.fetch`. Wire a + * custom `fetch` to thread auth-cookies, CSRF tokens, or + * service-worker interception. + */ + fetch?: typeof globalThis.fetch; + /** + * Extra HTTP headers applied to every RPC POST. Useful for app-level + * auth (CSRF, session cookies via custom header, etc.) — these are + * orthogonal to the ratchet authentication on the envelope itself. + */ + headers?: Record; +} + +interface RoundTripOpts { + signal?: AbortSignal; + timeoutMs?: number; + idempotencyKey?: string; +} + +/** + * Create a request-response `FileClient` bound to `peerAddress` and a + * server-side RPC URL. The session must already be established + * (via `shade.initSessionFromBundle(peerAddress, bundle)` or an + * incoming first-message). Otherwise the first RPC will fail with + * "decrypt failed: no session for peer". + */ +export function createFilesHttpClient( + shade: ShadeBridge, + peerAddress: string, + options: FilesHttpClientOptions, +): FileClient { + const rpcUrl = options.rpcUrl; + const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis); + const extraHeaders = options.headers ?? {}; + const defaultTimeoutMs = options.defaultTimeoutMs ?? 30_000; + const signRequest = options.signRequest; + const senderAddress = shade.myAddress; + + /** + * Encrypt + POST + decrypt + parse one RPC round-trip. + * + * Throws a typed `FileError` subclass when the server returns an + * encrypted `RpcError`, or `InternalFileError` for transport-level + * failures (network, 4xx/5xx, malformed body). + */ + async function roundTrip( + kind: string, + op: 'list' | 'stat' | 'mkdir' | 'delete' | 'move' | 'read' | 'write' | 'getThumbnail' | 'custom', + args: unknown, + resultSchema: ZodTypeAny, + opts: RoundTripOpts | undefined, + ): Promise { + const requestEnv = await buildRpcRequest({ + senderAddress, + kind, + op, + args, + ...(opts?.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + ...(signRequest !== undefined ? { signRequest } : {}), + }); + + const plaintext = encodeRpcEnvelope(requestEnv); + const ratchetEnvelope = await shade.send(peerAddress, plaintext); + const wireBytes = encodeWireEnvelope(ratchetEnvelope); + + const ac = new AbortController(); + const timeoutMs = opts?.timeoutMs ?? defaultTimeoutMs; + const timer = setTimeout( + () => ac.abort(new Error(`RPC timeout after ${timeoutMs}ms`)), + timeoutMs, + ); + (timer as unknown as { unref?: () => void }).unref?.(); + if (opts?.signal !== undefined) { + const userSignal = opts.signal; + if (userSignal.aborted) ac.abort(userSignal.reason); + else userSignal.addEventListener('abort', () => ac.abort(userSignal.reason), { once: true }); + } + + let response: Response; + try { + // Wrap the wire bytes in a Blob so the body type satisfies the + // common-denominator `BodyInit` across DOM, Bun, and node-fetch + // (some runtimes accept `Uint8Array` directly, others don't). + // Cast through `unknown` because TS's `bun-types` and `lib.dom` + // disagree about whether `Uint8Array` is itself + // a `BlobPart`; the runtime accepts it on every platform. + response = await fetchFn(rpcUrl, { + method: 'POST', + body: new Blob([wireBytes as unknown as ArrayBuffer]), + signal: ac.signal, + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Shade-Sender-Address': senderAddress, + ...extraHeaders, + }, + }); + } catch (err) { + clearTimeout(timer); + if ((err as Error).name === 'AbortError') { + throw new CancelledError(`RPC ${kind} aborted: ${(err as Error).message}`); + } + throw new InternalFileError(`RPC ${kind} fetch failed: ${(err as Error).message}`); + } + clearTimeout(timer); + + if (!response.ok) { + let body: { error?: string } | null = null; + try { + body = (await response.json()) as { error?: string }; + } catch { + /* server emitted non-JSON body */ + } + throw new InternalFileError( + `RPC ${kind} → ${response.status} ${response.statusText}: ${ + body?.error ?? '(no error body)' + }`, + ); + } + + const ab = await response.arrayBuffer(); + if (ab.byteLength === 0) { + throw new InternalFileError(`RPC ${kind}: empty response body`); + } + + let responseRatchet; + try { + responseRatchet = decodeEnvelope(new Uint8Array(ab)); + } catch (err) { + throw new InternalFileError( + `RPC ${kind}: response body is not a valid wire envelope: ${(err as Error).message}`, + ); + } + let responsePlaintext: string; + try { + responsePlaintext = await shade.receive(peerAddress, responseRatchet); + } catch (err) { + throw new InternalFileError( + `RPC ${kind}: response decrypt failed: ${(err as Error).message}`, + ); + } + const classified = tryParseEnvelope(responsePlaintext); + if (classified === null) { + throw new InternalFileError( + `RPC ${kind}: response plaintext is not a valid @shade/files envelope`, + ); + } + if (classified.kind === 'error') { + throw fileErrorFromPayload(classified.envelope.error); + } + if (classified.kind !== 'response') { + throw new InternalFileError( + `RPC ${kind}: unexpected response envelope kind: ${classified.kind}`, + ); + } + if (classified.envelope.id !== requestEnv.id) { + throw new InternalFileError( + `RPC ${kind}: response correlation id mismatch (got ${classified.envelope.id}, expected ${requestEnv.id})`, + ); + } + return resultSchema.parse(classified.envelope.result) as TResult; + } + + return { + async list(path, opts): Promise { + const args = ListArgsSchema.parse({ + path, + ...(opts?.cursor !== undefined ? { cursor: opts.cursor } : {}), + ...(opts?.pageSize !== undefined ? { pageSize: opts.pageSize } : {}), + ...(opts?.filter !== undefined ? { filter: opts.filter } : {}), + }); + return await roundTrip( + KIND_LIST_V1, + 'list', + args, + ListResultSchema, + opts, + ); + }, + + async stat(path, opts): Promise { + const args = StatArgsSchema.parse({ path }); + return await roundTrip(KIND_STAT_V1, 'stat', args, StatResultSchema, opts); + }, + + async mkdir(path, opts): Promise { + const args = MkdirArgsSchema.parse({ + path, + ...(opts?.recursive !== undefined ? { recursive: opts.recursive } : {}), + }); + return await roundTrip( + KIND_MKDIR_V1, + 'mkdir', + args, + MkdirResultSchema, + opts, + ); + }, + + async delete(path, opts): Promise { + const args = DeleteArgsSchema.parse({ + path, + ...(opts?.recursive !== undefined ? { recursive: opts.recursive } : {}), + }); + return await roundTrip( + KIND_DELETE_V1, + 'delete', + args, + DeleteResultSchema, + opts, + ); + }, + + async move(src, dst, opts): Promise { + const args = MoveArgsSchema.parse({ + src, + dst, + ...(opts?.overwrite !== undefined ? { overwrite: opts.overwrite } : {}), + }); + return await roundTrip(KIND_MOVE_V1, 'move', args, MoveResultSchema, opts); + }, + + async read(path, opts: ReadOpts = {}): Promise { + const args = ReadArgsSchema.parse({ + path, + ...(opts.range !== undefined ? { range: opts.range } : {}), + ...(opts.preferInline !== undefined ? { preferInline: opts.preferInline } : {}), + }); + const wire = await roundTrip( + KIND_READ_V1, + 'read', + args, + ReadResultSchema, + opts, + ); + if (wire.kind !== 'inline') { + // The HTTP RPC route does not service streamed reads — there is + // no place to stream from in pure request-response. + throw new InternalFileError( + `http RPC client received a streamed read (size ${wire.size}). Use shade.files.client(peer) on a server-to-server deployment, or pass { preferInline: true } when the file is known to fit inline.`, + ); + } + const bytes = base64ToBytes(wire.bytesB64); + const out: ReadOutput = { + kind: 'inline', + bytes, + size: wire.size, + sha256: wire.sha256, + ...(wire.contentType !== undefined ? { contentType: wire.contentType } : {}), + }; + return out; + }, + + async write(path, input: WriteSource, opts: WriteOpts = {}): Promise { + const decision = await decideInline(input); + const overwrite = opts.overwrite ?? false; + const contentType = opts.contentType ?? decision.contentType; + + if (decision.kind !== 'inline') { + throw new ConflictError( + `http RPC client supports inline writes only (≤ ${INLINE_THRESHOLD} bytes). The supplied input was promoted to streams (size ${decision.size ?? 'unknown'}). Use shade.files.client(peer) for streamed writes, or pre-buffer the input below the inline threshold.`, + ); + } + const args = WriteArgsSchema.parse({ + kind: 'inline', + path, + bytesB64: bytesToBase64(decision.bytes), + ...(contentType !== undefined ? { contentType } : {}), + overwrite, + }); + return await roundTrip( + KIND_WRITE_V1, + 'write', + args, + WriteResultSchema, + opts, + ); + }, + + async getThumbnail(path, size: ThumbnailSize, opts): Promise { + const args = GetThumbnailArgsSchema.parse({ + path, + size, + ...(opts?.format !== undefined ? { format: opts.format } : {}), + }); + const raw = await roundTrip( + KIND_GET_THUMBNAIL_V1, + 'getThumbnail', + args, + GetThumbnailResultSchema, + opts, + ); + return { + bytes: base64ToBytes(raw.bytesB64), + format: raw.format, + width: raw.width, + height: raw.height, + sha256: raw.sha256, + }; + }, + + async custom(name, args, opts?: BaseOpts): Promise { + const wireArgs = CustomArgsSchema.parse({ name, args }); + return await roundTrip(KIND_CUSTOM_V1, 'custom', wireArgs, CustomResultSchema, opts); + }, + + close(): void { + // Stateless — nothing to release. Exists for FileClient symmetry. + }, + } as FileClient; +} diff --git a/packages/shade-files/src/index.ts b/packages/shade-files/src/index.ts index 58a2ae2..8d4c1b9 100644 --- a/packages/shade-files/src/index.ts +++ b/packages/shade-files/src/index.ts @@ -183,6 +183,19 @@ export { MAX_SIGNATURE_AGE_MS } from './server/handler.js'; export { createFilesNamespace } from './integration/files-namespace.js'; export type { FilesNamespace } from './integration/files-namespace.js'; +// Request-response HTTP transport — for browser-style consumers +// (one HTTP POST per RPC, no inbound channel needed). See +// `docs/files.md § HTTP RPC`. +export { createFilesRpcRoute } from './server/rpc-route.js'; +export type { FilesRpcRouteOptions } from './server/rpc-route.js'; +export { createFilesHttpClient } from './client/http-client.js'; +export type { FilesHttpClientOptions } from './client/http-client.js'; + +// Shared structural surface @shade/files needs from a Shade instance — +// exposed so consumers building custom Shade-shaped bridges can verify +// they implement every required member. +export type { ShadeBridge } from './integration/shade-bridge.js'; + // Integration helpers — wire handler + pending registry onto a channel export { attachFileHandler } from './integration/wire-server.js'; export { attachClientRouting } from './integration/wire-client.js'; diff --git a/packages/shade-files/src/integration/files-namespace.ts b/packages/shade-files/src/integration/files-namespace.ts index 6cf7904..4873409 100644 --- a/packages/shade-files/src/integration/files-namespace.ts +++ b/packages/shade-files/src/integration/files-namespace.ts @@ -4,6 +4,7 @@ * so a single Shade can simultaneously serve files AND consume them from * peers without paying the setup cost twice. */ +import type { Hono } from 'hono'; import type { ShadeBridge } from './shade-bridge.js'; import { attachClientRouting, @@ -11,6 +12,8 @@ import { createClientStreamsBridge, createFileClient, createFileHandler, + createFilesHttpClient, + createFilesRpcRoute, createServerStreamsBridge, PendingRpcRegistry, ShadeFileRpcChannel, @@ -19,22 +22,79 @@ import { type FileClient, type FileHandler, type FileHandlerConfig, + type FilesHttpClientOptions, + type FilesRpcRouteOptions, type ServerStreamsBridge, } from '../index.js'; import { IdempotencyCache } from '../server/idempotency-cache.js'; +export interface ServeOptions { + /** + * Skip the streams bridge setup. Required for deployments that only + * use the HTTP RPC route ({@link FilesNamespace.rpcRoute}) — those + * deployments don't need to configure `@shade/transfer` because the + * RPC route only services inline payloads (≤ 256 KiB). Without this + * flag, `serve()` calls `createServerStreamsBridge(shade)` which + * eagerly instantiates the transfer engine and fails when + * `configureTransfers({ resolveBaseUrl })` has not been called. + * + * Default: `false` (build the streams bridge — full server-to-server + * stack with streamed reads/writes). + */ + inlineOnly?: boolean; +} + export interface FilesNamespace { /** * Register a file handler. Throws if a handler is already attached on * this Shade — only one server per Shade. The returned function detaches * the handler and tears down its idempotency / retention timers. + * + * Pass `{ inlineOnly: true }` for HTTP-RPC-only deployments to skip + * the streams-bridge setup (and the implied `configureTransfers` + * pre-condition). */ - serve(handler: FileHandlerConfig): Promise<() => Promise>; + serve( + handler: FileHandlerConfig, + options?: ServeOptions, + ): Promise<() => Promise>; /** * Build a typed file client for `peer`. Multiple concurrent clients to * different peers share the same channel + streams bridge. + * + * Use this for **server-to-server** deployments where both peers can + * receive inbound HTTP. For browser clients (no inbound listener), + * use {@link httpClient} instead. */ client(peer: string, opts?: Omit): Promise; + /** + * Build a request-response `FileClient` for browser-style consumers. + * Each RPC is one HTTP POST to the supplied `rpcUrl`; the encrypted + * response rides back in the same response body. No inbound channel + * required on the client side. + * + * Inline payloads only (≤ 256 KiB). Streamed reads/writes throw a + * clear error directing callers to {@link client} instead. + * + * Pre-condition: the session for `peer` must already be established + * (typically via `shade.initSessionFromBundle(peer, bundle)`). + */ + httpClient(peer: string, opts: FilesHttpClientOptions): FileClient; + /** + * Mount the server-side request-response RPC route. Returns a Hono + * app exposing `POST /rpc` that accepts encrypted file-RPC envelopes + * and returns encrypted responses in the same HTTP roundtrip. + * + * Mount under any base path: + * ```ts + * app.route('/api/v1/shade-files', shade.files.rpcRoute()); + * ``` + * + * Requires `shade.files.serve(...)` to have been called first — + * the route dispatches incoming requests through the attached + * handler. + */ + rpcRoute(opts?: FilesRpcRouteOptions): Hono; /** Tear down channel + bridges. After destroy(), serve()/client() throw. */ destroy(): Promise; } @@ -71,24 +131,39 @@ export function createFilesNamespace(shade: ShadeBridge): FilesNamespace { } return { - async serve(handlerConfig) { + async serve(handlerConfig, options = {}) { ensureAlive(); if (state.serverHandler !== null) { throw new Error('FilesNamespace: a handler is already registered (one per Shade)'); } - // Lazy server-side streams bridge. - if (state.serverBridge === null) { + // Lazy server-side streams bridge — skip when the deployment is + // HTTP-RPC-only and does not need `@shade/transfer` wired up. + if (!options.inlineOnly && state.serverBridge === null) { state.serverBridge = await createServerStreamsBridge(shade); } const inheritedObservability = shade.getObservability?.(); const handler = createFileHandler(shade, { ...handlerConfig, - streamsBridge: state.serverBridge, + ...(state.serverBridge !== null + ? { streamsBridge: state.serverBridge } + : {}), ...(handlerConfig.observability === undefined && inheritedObservability !== undefined ? { observability: inheritedObservability } : {}), }); - const detach = attachFileHandler(state.channel, handler); + // In inlineOnly mode, the rpc-route is the sole inbound path — + // do NOT also subscribe the channel's onMessage handler to this + // file handler, because that would cause every incoming request + // to be dispatched twice (once by the rpc-route's direct call, + // once by the channel's onMessage handler) and the channel-side + // response would attempt an outbound POST via + // `deliverControlEnvelope`, which is exactly the path that fails + // for browser clients. + const detach: () => void = options.inlineOnly + ? () => { + /* no channel subscription to detach */ + } + : attachFileHandler(state.channel, handler); state.serverHandler = handler; state.serverDetach = detach; @@ -131,6 +206,21 @@ export function createFilesNamespace(shade: ShadeBridge): FilesNamespace { }); }, + httpClient(peer, opts) { + ensureAlive(); + return createFilesHttpClient(shade, peer, opts); + }, + + rpcRoute(opts = {}) { + ensureAlive(); + if (state.serverHandler === null) { + throw new Error( + 'FilesNamespace.rpcRoute(): no handler attached. Call shade.files.serve(...) before mounting the RPC route.', + ); + } + return createFilesRpcRoute(shade, state.serverHandler, opts); + }, + async destroy() { if (state.destroyed) return; state.destroyed = true; diff --git a/packages/shade-files/src/integration/shade-bridge.ts b/packages/shade-files/src/integration/shade-bridge.ts index 01ffd15..26c820d 100644 --- a/packages/shade-files/src/integration/shade-bridge.ts +++ b/packages/shade-files/src/integration/shade-bridge.ts @@ -32,6 +32,12 @@ export interface ShadeBridge { /** Encrypt + send `plaintext` to `peer`; returns the wire envelope. */ send(peer: string, plaintext: string): Promise; + /** + * Decrypt an inbound envelope from `peer` and return the plaintext. + * Used by the request-response RPC route on the server side. + */ + receive(peer: string, envelope: ShadeEnvelope): Promise; + /** * Subscribe to incoming ratchet plaintext. Returns an unsubscribe. * Handlers may be sync or async; async handlers are awaited in diff --git a/packages/shade-files/src/protocol/rpc-builder.ts b/packages/shade-files/src/protocol/rpc-builder.ts new file mode 100644 index 0000000..1cfa26f --- /dev/null +++ b/packages/shade-files/src/protocol/rpc-builder.ts @@ -0,0 +1,126 @@ +/** + * Shared RPC-request construction. + * + * Both the channel-based `FileClient` (`createFileClient`) and the + * HTTP-based `FilesHttpClient` build identical `RpcRequest` envelopes + * — they differ only in *transport* (channel.send → ratchet via + * Shade.send/onMessage vs HTTP POST → ratchet via single + * request-response). This module is the single source of truth for + * the wire shape so the two clients can never drift. + */ +import type { ZodTypeAny } from 'zod'; +import { + KIND_DELETE_V1, + KIND_GET_THUMBNAIL_V1, + KIND_LIST_V1, + KIND_MKDIR_V1, + KIND_MOVE_V1, + KIND_READ_V1, + KIND_STAT_V1, + KIND_WRITE_V1, + MUTATION_OPS, + type StandardOp, +} from './kinds.js'; +import { generateIdempotencyKey, generateRequestId } from './correlate.js'; +import { canonicalRpcBytes, hashArgs } from './canonical.js'; +import type { RpcRequest } from '../schemas/envelope.js'; + +export const KIND_BY_OP: Record = { + list: KIND_LIST_V1, + stat: KIND_STAT_V1, + mkdir: KIND_MKDIR_V1, + delete: KIND_DELETE_V1, + move: KIND_MOVE_V1, + read: KIND_READ_V1, + write: KIND_WRITE_V1, + getThumbnail: KIND_GET_THUMBNAIL_V1, +}; + +export type SignRequest = (canonicalBytes: Uint8Array) => Promise | string; + +export interface BuildRpcRequestOptions { + /** Address that this RPC call originates from. */ + senderAddress: string; + /** RPC kind — `KIND_*_V1` constants for standard ops, `KIND_CUSTOM_V1` otherwise. */ + kind: string; + /** Op classifier so mutations get an auto-generated idempotency key. */ + op: StandardOp | 'custom'; + /** Validated args object. The caller is responsible for `Zod.parse(args)`. */ + args: unknown; + /** Caller-supplied idempotency key. Mutations get one auto-generated. */ + idempotencyKey?: string; + /** Optional Ed25519-style signer over the canonical bytes. */ + signRequest?: SignRequest; +} + +/** + * Build a single `RpcRequest` envelope. Generates `id`, `signedAt`, + * idempotency key (mutations only), and the canonical signature. + */ +export async function buildRpcRequest( + options: BuildRpcRequestOptions, +): Promise { + const { senderAddress, kind, op, args, signRequest } = options; + const requestId = generateRequestId(); + const isMutation = MUTATION_OPS.has(op); + const idempotencyKey = + options.idempotencyKey ?? (isMutation ? generateIdempotencyKey() : undefined); + const signedAt = Date.now(); + + let sig = 'unsigned'; + if (signRequest !== undefined) { + const canonical = canonicalRpcBytes({ + address: senderAddress, + signedAt, + kind, + id: requestId, + argsHash: hashArgs(args), + }); + sig = await signRequest(canonical); + } + + const env: RpcRequest = { + kind, + id: requestId, + args, + ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), + sig, + signedAt, + }; + return env; +} + +/** + * Helper for the typed standard ops: validates args via the supplied Zod + * schema, calls {@link buildRpcRequest}, and forwards the result. + * + * The HTTP and channel clients both call this from each op-method to + * guarantee identical wire-shape. Custom ops use {@link buildRpcRequest} + * directly because they ship `KIND_CUSTOM_V1` plus runtime-validated args. + */ +export async function buildStandardRpcRequest( + schema: { parse(input: unknown): TArgs }, + rawArgs: unknown, + options: Omit & { + op: StandardOp; + }, +): Promise<{ request: RpcRequest; args: TArgs }> { + const args = schema.parse(rawArgs) as TArgs; + const kind = KIND_BY_OP[options.op]; + const request = await buildRpcRequest({ + ...options, + kind, + op: options.op, + args, + }); + return { request, args }; +} + +/** + * Validate a returned RpcResponse `result` against the supplied schema. + * Centralised so the HTTP and channel clients fail-loudly with the same + * error type when a server returns an off-spec response. + */ +export function parseResult(schema: ZodTypeAny, raw: unknown): T { + return schema.parse(raw) as T; +} diff --git a/packages/shade-files/src/server/rpc-route.ts b/packages/shade-files/src/server/rpc-route.ts new file mode 100644 index 0000000..6985836 --- /dev/null +++ b/packages/shade-files/src/server/rpc-route.ts @@ -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 /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; +} diff --git a/packages/shade-files/tests/integration/http-rpc.test.ts b/packages/shade-files/tests/integration/http-rpc.test.ts new file mode 100644 index 0000000..012ee0b --- /dev/null +++ b/packages/shade-files/tests/integration/http-rpc.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, test } from 'bun:test'; +import { createShade } from '@shade/sdk'; +import { + createPrekeyServer, + MemoryPrekeyStore, + PrekeyServerEvents, +} from '@shade/server'; +import { SubtleCryptoProvider } from '@shade/crypto-web'; + +const crypto = new SubtleCryptoProvider(); + +/** + * Stand up a prekey server + two Shades + Bob's file handler + RPC route + * mounted on Bun.serve, then return Alice's HTTP-only `FileClient`. + * + * Mirrors the request-response setup a browser client would use against a + * Bun-style server. + */ +async function setupHttpRig(opts: { + bobHandler: Parameters>['files']>['serve']>[0]; +}) { + // 1. Prekey server. + const prekeyEvents = new PrekeyServerEvents(); + const prekey = createPrekeyServer({ + crypto, + store: new MemoryPrekeyStore(), + disableRateLimit: true, + events: prekeyEvents, + }); + const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch }); + const prekeyUrl = `http://localhost:${prekeyServer.port}`; + + // 2. Two Shades. Alice plays the browser client (no transferRoute); + // Bob is the server. + const alice = await createShade({ prekeyServer: prekeyUrl, address: 'alice' }); + const bob = await createShade({ prekeyServer: prekeyUrl, address: 'bob' }); + + // 3. Bob: register file handler (HTTP-only — no streams) + mount + // the RPC route. + await bob.files.serve(opts.bobHandler, { inlineOnly: true }); + const rpcRoute = bob.files.rpcRoute({ acceptFirstMessage: true }); + const bobServer = Bun.serve({ port: 0, fetch: rpcRoute.fetch }); + const rpcUrl = `http://localhost:${bobServer.port}/rpc`; + + // 5. Alice: build the HTTP-only file client. + const fs = alice.files.httpClient('bob', { rpcUrl, defaultTimeoutMs: 5000 }); + + return { + alice, + bob, + fs, + rpcUrl, + teardown: async () => { + await alice.shutdown(); + await bob.shutdown(); + bobServer.stop(); + prekeyServer.stop(); + }, + }; +} + +describe('@shade/files HTTP RPC — round-trip', () => { + test('list → mkdir → stat → write inline → read inline → delete via httpClient', async () => { + interface VfsEntry { + kind: 'file' | 'dir'; + bytes?: Uint8Array; + contentType?: string; + } + const vfs = new Map([ + ['/', { kind: 'dir' }], + ['/photos', { kind: 'dir' }], + ]); + + const rig = await setupHttpRig({ + bobHandler: { + list: async (ctx) => { + const prefix = ctx.path.endsWith('/') ? ctx.path : `${ctx.path}/`; + const entries = Array.from(vfs.entries()) + .filter(([p]) => p.startsWith(prefix) && p !== ctx.path && !p.slice(prefix.length).includes('/')) + .map(([p, e]) => ({ + name: p.slice(prefix.length) || p, + kind: e.kind, + size: e.bytes?.byteLength ?? 0, + mtime: 0, + metadata: {}, + })); + return { entries, hasMore: false }; + }, + stat: async (ctx) => { + const e = vfs.get(ctx.path); + if (!e) throw new (await import('../../src/index.js')).NotFoundError(`stat ${ctx.path}`); + return { + name: ctx.path.split('/').pop() ?? ctx.path, + kind: e.kind, + size: e.bytes?.byteLength ?? 0, + mtime: 0, + metadata: {}, + ...(e.contentType !== undefined ? { contentType: e.contentType } : {}), + }; + }, + mkdir: async (ctx) => { + vfs.set(ctx.path, { kind: 'dir' }); + return { entry: { name: ctx.path.split('/').pop() ?? ctx.path, kind: 'dir' as const, size: 0, mtime: 0, metadata: {} } }; + }, + delete: async (ctx) => { + if (!vfs.has(ctx.path)) { + throw new (await import('../../src/index.js')).NotFoundError(`delete ${ctx.path}`); + } + vfs.delete(ctx.path); + return { deletedCount: 1 }; + }, + read: async (ctx) => { + const e = vfs.get(ctx.path); + if (!e || e.kind !== 'file' || !e.bytes) { + throw new (await import('../../src/index.js')).NotFoundError(`read ${ctx.path}`); + } + // Omit sha256 — dispatcher computes it from the bytes. + return { + kind: 'inline' as const, + bytes: e.bytes, + ...(e.contentType !== undefined ? { contentType: e.contentType } : {}), + }; + }, + write: async (ctx) => { + if (ctx.args.content.kind !== 'inline') { + throw new (await import('../../src/index.js')).ConflictError('streams not supported in this test handler'); + } + vfs.set(ctx.args.path, { + kind: 'file', + bytes: ctx.args.content.bytes, + ...(ctx.args.contentType !== undefined ? { contentType: ctx.args.contentType } : {}), + }); + return { + entry: { + name: ctx.args.path.split('/').pop() ?? ctx.args.path, + kind: 'file' as const, + size: ctx.args.content.bytes.byteLength, + mtime: 0, + metadata: {}, + ...(ctx.args.contentType !== undefined ? { contentType: ctx.args.contentType } : {}), + }, + }; + }, + }, + }); + + try { + // list + const listed = await rig.fs.list('/'); + expect(listed.entries.map((e) => e.name).sort()).toContain('photos'); + + // mkdir + await rig.fs.mkdir('/docs'); + const stat = await rig.fs.stat('/docs'); + expect(stat.kind).toBe('dir'); + + // write inline + const payload = new TextEncoder().encode('hello browser-friendly world'); + const writeResult = await rig.fs.write('/docs/greeting.txt', payload, { + contentType: 'text/plain', + }); + expect(writeResult.entry.size).toBe(payload.byteLength); + + // read inline + const readResult = await rig.fs.read('/docs/greeting.txt'); + expect(readResult.kind).toBe('inline'); + if (readResult.kind === 'inline') { + expect(new TextDecoder().decode(readResult.bytes)).toBe('hello browser-friendly world'); + expect(readResult.contentType).toBe('text/plain'); + } + + // delete + const del = await rig.fs.delete('/docs/greeting.txt'); + expect(del.deletedCount).toBe(1); + + // stat the deleted path → typed NotFoundError + const { NotFoundError } = await import('../../src/index.js'); + await expect(rig.fs.stat('/docs/greeting.txt')).rejects.toBeInstanceOf(NotFoundError); + } finally { + await rig.teardown(); + } + }); + + test('streamed write (> 256 KiB) is rejected with a clear error', async () => { + const rig = await setupHttpRig({ + bobHandler: { + write: async () => ({ + entry: { name: 'unused', kind: 'file' as const, size: 0, mtime: 0, metadata: {} }, + }), + }, + }); + try { + const big = new Uint8Array(257 * 1024); + const { ConflictError } = await import('../../src/index.js'); + await expect(rig.fs.write('/big.bin', big)).rejects.toBeInstanceOf(ConflictError); + } finally { + await rig.teardown(); + } + }); + + test('rpcRoute() throws when no handler is attached', async () => { + // Don't call shade.files.serve(...) — rpcRoute() should refuse. + const prekeyEvents = new PrekeyServerEvents(); + const prekey = createPrekeyServer({ + crypto, + store: new MemoryPrekeyStore(), + disableRateLimit: true, + events: prekeyEvents, + }); + const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch }); + const bob = await createShade({ + prekeyServer: `http://localhost:${prekeyServer.port}`, + address: 'bob', + }); + try { + expect(() => bob.files.rpcRoute()).toThrow(/no handler attached/); + } finally { + await bob.shutdown(); + prekeyServer.stop(); + } + }); + + test('missing X-Shade-Sender-Address header → 400', async () => { + const rig = await setupHttpRig({ + bobHandler: { + stat: async () => ({ + name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {}, + }), + }, + }); + try { + const res = await fetch(rig.rpcUrl, { + method: 'POST', + body: new Uint8Array([0]), + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/X-Shade-Sender-Address/); + } finally { + await rig.teardown(); + } + }); + + test('empty body → 400', async () => { + const rig = await setupHttpRig({ + bobHandler: { + stat: async () => ({ + name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {}, + }), + }, + }); + try { + const res = await fetch(rig.rpcUrl, { + method: 'POST', + headers: { 'X-Shade-Sender-Address': 'alice' }, + }); + expect(res.status).toBe(400); + } finally { + await rig.teardown(); + } + }); + + test('garbage body → 401 decrypt failure', async () => { + const rig = await setupHttpRig({ + bobHandler: { + stat: async () => ({ + name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {}, + }), + }, + }); + try { + const res = await fetch(rig.rpcUrl, { + method: 'POST', + headers: { 'X-Shade-Sender-Address': 'alice' }, + body: new Uint8Array([0x02, 0xff, 0xff, 0xff]), + }); + // 400 from envelope decode failure or 401 from decrypt failure. + expect([400, 401]).toContain(res.status); + } finally { + await rig.teardown(); + } + }); + + test('body past maxBodyBytes → 413', async () => { + const prekeyEvents = new PrekeyServerEvents(); + const prekey = createPrekeyServer({ + crypto, + store: new MemoryPrekeyStore(), + disableRateLimit: true, + events: prekeyEvents, + }); + const prekeyServer = Bun.serve({ port: 0, fetch: prekey.fetch }); + const bob = await createShade({ + prekeyServer: `http://localhost:${prekeyServer.port}`, + address: 'bob', + }); + await bob.files.serve( + { + stat: async () => ({ + name: '_', kind: 'dir' as const, size: 0, mtime: 0, metadata: {}, + }), + }, + { inlineOnly: true }, + ); + const route = bob.files.rpcRoute({ maxBodyBytes: 1024 }); + const server = Bun.serve({ port: 0, fetch: route.fetch }); + try { + const big = new Uint8Array(2048); + const res = await fetch(`http://localhost:${server.port}/rpc`, { + method: 'POST', + headers: { 'X-Shade-Sender-Address': 'alice' }, + body: big, + }); + expect(res.status).toBe(413); + } finally { + await bob.shutdown(); + server.stop(); + prekeyServer.stop(); + } + }); +}); diff --git a/packages/shade-inbox-server/package.json b/packages/shade-inbox-server/package.json index 3c38609..6e5c3a7 100644 --- a/packages/shade-inbox-server/package.json +++ b/packages/shade-inbox-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox-server", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox/package.json b/packages/shade-inbox/package.json index 393760e..6d949eb 100644 --- a/packages/shade-inbox/package.json +++ b/packages/shade-inbox/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-key-transparency/package.json b/packages/shade-key-transparency/package.json index f2f95fb..07386e9 100644 --- a/packages/shade-key-transparency/package.json +++ b/packages/shade-key-transparency/package.json @@ -1,6 +1,6 @@ { "name": "@shade/key-transparency", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-keychain/package.json b/packages/shade-keychain/package.json index bdce624..efb1e6a 100644 --- a/packages/shade-keychain/package.json +++ b/packages/shade-keychain/package.json @@ -1,6 +1,6 @@ { "name": "@shade/keychain", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observability/package.json b/packages/shade-observability/package.json index 43aa3b3..584a03b 100644 --- a/packages/shade-observability/package.json +++ b/packages/shade-observability/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observability", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observer/package.json b/packages/shade-observer/package.json index a770cd5..fa663ef 100644 --- a/packages/shade-observer/package.json +++ b/packages/shade-observer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observer", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-proto/package.json b/packages/shade-proto/package.json index 502e834..86eaef2 100644 --- a/packages/shade-proto/package.json +++ b/packages/shade-proto/package.json @@ -1,6 +1,6 @@ { "name": "@shade/proto", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-recovery/package.json b/packages/shade-recovery/package.json index 0c41a35..57786f5 100644 --- a/packages/shade-recovery/package.json +++ b/packages/shade-recovery/package.json @@ -1,6 +1,6 @@ { "name": "@shade/recovery", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-sdk/package.json b/packages/shade-sdk/package.json index a20c293..5c0afa8 100644 --- a/packages/shade-sdk/package.json +++ b/packages/shade-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@shade/sdk", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json index 9121423..4a4457a 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/server", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-encrypted/package.json b/packages/shade-storage-encrypted/package.json index 0379e3a..f5bfb45 100644 --- a/packages/shade-storage-encrypted/package.json +++ b/packages/shade-storage-encrypted/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-encrypted", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-postgres/package.json b/packages/shade-storage-postgres/package.json index 04bb76f..68c2b33 100644 --- a/packages/shade-storage-postgres/package.json +++ b/packages/shade-storage-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-postgres", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-sqlite/package.json b/packages/shade-storage-sqlite/package.json index 04060d0..af80b9e 100644 --- a/packages/shade-storage-sqlite/package.json +++ b/packages/shade-storage-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-sqlite", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-streams/package.json b/packages/shade-streams/package.json index a29b023..6c9f61d 100644 --- a/packages/shade-streams/package.json +++ b/packages/shade-streams/package.json @@ -1,6 +1,6 @@ { "name": "@shade/streams", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transfer/package.json b/packages/shade-transfer/package.json index 5ea647f..104beb8 100644 --- a/packages/shade-transfer/package.json +++ b/packages/shade-transfer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transfer", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-bridge/package.json b/packages/shade-transport-bridge/package.json index 717d584..47872cd 100644 --- a/packages/shade-transport-bridge/package.json +++ b/packages/shade-transport-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-bridge", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-webrtc/package.json b/packages/shade-transport-webrtc/package.json index f46ab35..cf8209a 100644 --- a/packages/shade-transport-webrtc/package.json +++ b/packages/shade-transport-webrtc/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-webrtc", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport/package.json b/packages/shade-transport/package.json index 74b7b9e..39439a4 100644 --- a/packages/shade-transport/package.json +++ b/packages/shade-transport/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-widgets/package.json b/packages/shade-widgets/package.json index 8a5acb4..442d168 100644 --- a/packages/shade-widgets/package.json +++ b/packages/shade-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@shade/widgets", - "version": "4.0.2", + "version": "4.1.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts",