import type { Hono, Context } from 'hono'; import type { TransferEngine } from '../engine.js'; import { TransferProtocolError } from '../errors.js'; /** * Auth contract for incoming chunk POSTs / control GETs. Verifies the * signature attached by the client's `TransferAuthenticator` and returns * the resolved sender address. */ export interface TransferRouteAuthenticator { verifyChunk(args: { request: Request; streamId: string; laneId: number; seq: bigint; bodyHash: Uint8Array; }): Promise<{ senderAddress: string }>; verifyControl(args: { request: Request; streamId: string; method: string; path: string; }): Promise<{ senderAddress: string }>; } export interface TransferRouteOptions { /** Maximum chunk body size in bytes. Default 16 MiB. */ maxChunkBytes?: number; /** Server-side authenticator. Defaults to a permissive one for trusted/local nets. */ authenticator?: TransferRouteAuthenticator; } /** * Permissive authenticator: extracts sender address from the * `X-Shade-Sender-Address` header without verification. Suitable ONLY for * trusted/local network testing; the SDK's M-Stream-5 integration replaces * this with an Ed25519-verifying implementation. */ export const PermissiveAuthenticator: TransferRouteAuthenticator = { async verifyChunk({ request }) { const senderAddress = request.headers.get('X-Shade-Sender-Address'); if (senderAddress === null || senderAddress === '') { throw new TransferProtocolError('Missing X-Shade-Sender-Address header'); } return { senderAddress }; }, async verifyControl({ request }) { const senderAddress = request.headers.get('X-Shade-Sender-Address'); if (senderAddress === null || senderAddress === '') { throw new TransferProtocolError('Missing X-Shade-Sender-Address header'); } return { senderAddress }; }, }; const DEFAULT_MAX_CHUNK_BYTES = 16 * 1024 * 1024 + 1024; /** * Mount the transfer-receive routes on a Hono router. Returns the same * Hono instance for fluent composition. The consumer mounts under any * base path (e.g. `app.route('/shade', createTransferRoutes(engine))`). * * Hono is a peer-dep so non-server consumers can omit it. */ export async function createTransferRoutes( engine: TransferEngine, options: TransferRouteOptions = {}, ): Promise { const { Hono: HonoCtor } = (await import('hono')) as { Hono: new () => Hono }; const app = new HonoCtor(); const auth = options.authenticator ?? PermissiveAuthenticator; const maxBytes = options.maxChunkBytes ?? DEFAULT_MAX_CHUNK_BYTES; app.get('/v1/transfer/health', (c) => c.json({ ok: true })); app.post('/v1/transfer/:streamId/chunk', async (c) => { const streamId = c.req.param('streamId'); const laneIdRaw = c.req.header('X-Shade-Lane-Id'); const seqRaw = c.req.header('X-Shade-Seq'); if (laneIdRaw === undefined || seqRaw === undefined) { return c.json({ error: 'missing X-Shade-Lane-Id or X-Shade-Seq' }, 400); } const laneId = Number(laneIdRaw); const seq = BigInt(seqRaw); if (!Number.isInteger(laneId) || laneId < 0) { return c.json({ error: 'invalid lane id' }, 400); } const contentLength = c.req.header('content-length'); if (contentLength !== undefined && Number(contentLength) > maxBytes) { return c.json({ error: `chunk exceeds maxChunkBytes (${maxBytes})` }, 413); } const ab = await c.req.arrayBuffer(); if (ab.byteLength > maxBytes) { return c.json({ error: `chunk exceeds maxChunkBytes (${maxBytes})` }, 413); } const body = new Uint8Array(ab); const bodyHash = new Uint8Array( await globalThis.crypto.subtle.digest('SHA-256', ab), ); let senderAddress: string; try { const result = await auth.verifyChunk({ request: c.req.raw, streamId, laneId, seq, bodyHash, }); senderAddress = result.senderAddress; } catch (err) { return errorResponse(c, err); } try { const ack = await engine.receiveChunk(senderAddress, streamId, laneId, seq, body); return c.json(ack); } catch (err) { return errorResponse(c, err); } }); app.get('/v1/transfer/:streamId/state', async (c) => { const streamId = c.req.param('streamId'); let senderAddress: string; try { const result = await auth.verifyControl({ request: c.req.raw, streamId, method: 'GET', path: c.req.path, }); senderAddress = result.senderAddress; } catch (err) { return errorResponse(c, err); } const state = await engine.getResumeState(senderAddress, streamId); if (state === null) return c.json({ error: 'no state' }, 404); return c.json(state); }); return app; } function errorResponse(c: Context, err: unknown): Response { const message = err instanceof Error ? err.message : String(err); const code = err instanceof Error && (err as unknown as { code?: unknown }).code !== undefined ? String((err as unknown as { code: unknown }).code) : 'UNKNOWN'; let status = 500; if (code === 'SHADE_TRANSFER_PROTOCOL') status = 400; if (code === 'SHADE_VALIDATION') status = 400; if (code === 'SHADE_UNAUTHORIZED') status = 401; if (code === 'SHADE_INVALID_SIGNATURE') status = 401; if (code === 'SHADE_STREAM_REPLAY') status = 409; if (code === 'SHADE_STREAM_OUT_OF_ORDER') status = 409; return c.json({ error: message, code }, status as 400 | 401 | 409 | 500); }