import type { ShadeBridge } from '../integration/shade-bridge.js'; import type { ZodTypeAny } from 'zod'; import { MUTATION_OPS, opOfKind, responseKindOf, type StandardOp, } from '../protocol/kinds.js'; import { DeleteArgsSchema, DeleteResultSchema, GetThumbnailArgsSchema, GetThumbnailResultSchema, ListArgsSchema, ListResultSchema, MkdirArgsSchema, MkdirResultSchema, MoveArgsSchema, MoveResultSchema, ReadArgsSchema, ReadResultSchema, StatArgsSchema, StatResultSchema, WriteArgsSchema, WriteResultSchema, type DeleteArgs, type DeleteResult, type GetThumbnailArgs, type ListArgs, type ListResult, type MkdirArgs, type MkdirResult, type MoveArgs, type MoveResult, type ReadArgs, type StatArgs, type StatResult, type WriteArgs, type WriteResult, } from '../schemas/ops.js'; import { CancelledError, CustomOpRejectedError, FileError, FingerprintRequiredError, InvalidArgsError, InvalidSignatureError, NotImplementedError, PathValidationError, payloadFromError, } from '../schemas/errors.js'; import type { RpcCancel, RpcError, RpcRequest, RpcResponse } from '../schemas/envelope.js'; import { buildOpContext, type OpContext } from './handler-context.js'; import { IdempotencyCache, type IdempotencyCacheOptions } from './idempotency-cache.js'; import { RateLimiter, type RateLimitConfig } from './rate-limiter.js'; import { validatePath, type PathPolicy } from './path-policy.js'; import { adaptReadResult, adaptThumbnailResult, adaptWriteArgs, } from './io-adapters.js'; import type { UserReadResult, UserThumbnailResult, UserWriteArgs, } from './io-types.js'; import type { ServerStreamsBridge } from './streams-bridge.js'; import type { CustomOpRegistrations } from './custom-ops.js'; import { METRIC_BYTES_IN, METRIC_BYTES_OUT, METRIC_FINGERPRINT_REJECT_TOTAL, METRIC_IDEMPOTENCY_CONFLICT_TOTAL, METRIC_IDEMPOTENCY_HIT_TOTAL, METRIC_OP_DURATION_MS, METRIC_OP_TOTAL, METRIC_RATE_LIMIT_REJECT_TOTAL, METRIC_SIGNATURE_REJECT_TOTAL, NOOP_METRIC_SINK, type MetricSink, } from './metrics.js'; import { ATTR_BYTES_BIN, ATTR_ERROR_CODE, ATTR_OP, ATTR_PEER_HASH, ATTR_RESULT, bytesBin, NOOP_HOOK, peerHash, type ObservabilityHook, } from '@shade/observability'; import { CustomArgsSchema, CustomResultSchema, type CustomArgs, } from '../schemas/ops.js'; import { canonicalRpcBytes, hashArgs } from '../protocol/canonical.js'; /** Replay window for the `signedAt` field on inbound RPC envelopes. */ export const MAX_SIGNATURE_AGE_MS = 5 * 60 * 1000; // ─── Public types ──────────────────────────────────────────── export interface FileHandlerOps { list?: (ctx: OpContext) => Promise; stat?: (ctx: OpContext) => Promise; mkdir?: (ctx: OpContext) => Promise; delete?: (ctx: OpContext) => Promise; move?: (ctx: OpContext) => Promise; /** * User-supplied read handler. Returns either an `inline` payload (≤ 256 * KiB) or a `streams` payload with a precomputed sha256. The dispatcher * adapts to the wire shape. */ read?: (ctx: OpContext) => Promise; /** * User-supplied write handler. Receives `UserWriteArgs` with a clean * `Uint8Array` (inline) or `ReadableStream` + sha256-promise (streams) * shape — the dispatcher hides the base64 / writeId wire details. */ write?: (ctx: OpContext) => Promise; /** * User-supplied thumbnail handler. Bytes are validated for format magic * before they're shipped to prevent format misclassification attacks. */ getThumbnail?: (ctx: OpContext) => Promise; } export interface FileHandlerConfig extends FileHandlerOps { pathPolicy?: PathPolicy; rateLimits?: RateLimitConfig; idempotency?: IdempotencyCacheOptions; /** * Required for read/write `streams` ops. Coordinates the inbound/outbound * `@shade/transfer` transfers via `userMetadata.shadeFiles*Id` keys. */ streamsBridge?: ServerStreamsBridge; /** Custom ops registry — see `CustomOpsMap` declaration-merging. */ custom?: CustomOpRegistrations; /** * Verify the Ed25519 signature on inbound RPC envelopes. Pluggable so * apps can plug their own peer-identity store. Returning `false` rejects * with `InvalidSignatureError`. Default: skip (Double Ratchet AEAD on * the underlying envelope already authenticates the sender). * * The `signedAt` replay-window check (±5 min) is enforced regardless. */ verifySender?: ( sender: string, canonicalBytes: Uint8Array, sig: string, ) => boolean | Promise; /** * Per-op fingerprint-verification gate. Return `'required'` to demand * the peer's fingerprint has been verified out-of-band (via * `isFingerprintVerified`); `'reject'` to deny outright; * `'optional'` (default) to allow. */ requireFingerprintVerifiedFor?: ( ctx: OpContext, ) => 'required' | 'optional' | 'reject' | Promise<'required' | 'optional' | 'reject'>; /** Lookup whether the consumer has out-of-band verified the peer. */ isFingerprintVerified?: (sender: string) => boolean | Promise; /** Vendor-neutral metrics sink. */ onMetric?: MetricSink; /** * Optional OTel observability hook. When supplied, each op is wrapped in * a `shade.files.op` span with PII-safe attributes (peer.hash, op, * bytes.bin, result, error.code). Defaults to no-op when omitted. */ observability?: ObservabilityHook; /** Called BEFORE the handler runs. Throw to deny. */ beforeOp?: (ctx: OpContext) => void | Promise; /** Called AFTER the handler returns. Result is the validated response. */ afterOp?: (ctx: OpContext, result: unknown) => void | Promise; /** Called when an op fails. Receives the error and the context. */ onError?: (err: unknown, ctx: OpContext) => void; /** Default per-op timeout in ms. Default 60_000. */ defaultTimeoutMs?: number; /** Hard deadline for streams-bridge awaits / outbound transfers. Default 60_000. */ ioTimeoutMs?: number; } export interface FileHandler { /** Handle an incoming request envelope. Returns the envelope to send back. */ handleRequest(from: string, request: RpcRequest): Promise; /** Handle an incoming cancel envelope. */ handleCancel(from: string, cancel: RpcCancel): void; /** Free up internal state (timers, abort listeners). */ destroy(): void; } interface OpSchemaPair { args: ZodTypeAny; result: ZodTypeAny; } const OP_SCHEMAS: Record = { list: { args: ListArgsSchema, result: ListResultSchema }, stat: { args: StatArgsSchema, result: StatResultSchema }, mkdir: { args: MkdirArgsSchema, result: MkdirResultSchema }, delete: { args: DeleteArgsSchema, result: DeleteResultSchema }, move: { args: MoveArgsSchema, result: MoveResultSchema }, read: { args: ReadArgsSchema, result: ReadResultSchema }, write: { args: WriteArgsSchema, result: WriteResultSchema }, getThumbnail: { args: GetThumbnailArgsSchema, result: GetThumbnailResultSchema }, }; // ─── createFileHandler ─────────────────────────────────────── /** * Build a `FileHandler` for the server side. The returned object is * registered with `ShadeFileRpcChannel.setHooks({ onRequest })` (typically * via `Shade.files.serve(...)` in the SDK). */ export function createFileHandler( shade: ShadeBridge, config: FileHandlerConfig, ): FileHandler { const idempotency = new IdempotencyCache(config.idempotency); const rateLimiter = new RateLimiter(config.rateLimits); const inflightCancellers = new Map(); const defaultTimeoutMs = config.defaultTimeoutMs ?? 60_000; const ioTimeoutMs = config.ioTimeoutMs ?? 60_000; const metrics: MetricSink = config.onMetric ?? NOOP_METRIC_SINK; const observability: ObservabilityHook = config.observability ?? NOOP_HOOK; const customRegistrations = config.custom ?? {}; const isCustomKind = (kind: string): boolean => kind === 'shade.fs.custom/v1'; async function handleRequest( from: string, request: RpcRequest, ): Promise { const span = observability.startSpan('shade.files.op', { [ATTR_PEER_HASH]: peerHash(from), }); try { const out = await runHandleRequest(from, request, span); return out; } catch (err) { span.recordException(err); span.setAttribute(ATTR_ERROR_CODE, errorCodeOf(err)); span.setStatus('error'); throw err; } finally { span.end(); } } async function runHandleRequest( from: string, request: RpcRequest, span: import('@shade/observability').Span, ): Promise { // 0. Replay-window check (independent of sig — defends against // intercept-and-resend even when sig verification is disabled). const skewMs = Math.abs(Date.now() - request.signedAt); if (skewMs > MAX_SIGNATURE_AGE_MS) { metrics(METRIC_SIGNATURE_REJECT_TOTAL, 1, { reason: 'skew' }); return makeErrorEnvelope( request, new InvalidSignatureError( `signedAt is ${skewMs}ms outside the ±${MAX_SIGNATURE_AGE_MS}ms replay window`, ), ); } // 0b. Optional Ed25519 sig verification — pluggable. The canonical // bytes bind sender + kind + id + signedAt + sha256(args). if (config.verifySender !== undefined) { const argsHashBytes = hashArgs(request.args); const canonical = canonicalRpcBytes({ address: from, signedAt: request.signedAt, kind: request.kind, id: request.id, argsHash: argsHashBytes, }); let ok = false; try { ok = await config.verifySender(from, canonical, request.sig); } catch (err) { metrics(METRIC_SIGNATURE_REJECT_TOTAL, 1, { reason: 'throw' }); return makeErrorEnvelope(request, err); } if (!ok) { metrics(METRIC_SIGNATURE_REJECT_TOTAL, 1, { reason: 'mismatch' }); return makeErrorEnvelope(request, new InvalidSignatureError()); } } // 1. Resolve op + handler. Custom ops route through `config.custom`. let op: StandardOp | 'custom'; let argSchema: ZodTypeAny; let resultSchema: ZodTypeAny; let customHandler: CustomOpRegistrations[string] | undefined; if (isCustomKind(request.kind)) { op = 'custom'; argSchema = CustomArgsSchema; resultSchema = CustomResultSchema; } else { const std = opOfKind(request.kind); if (std === null) { return makeErrorEnvelope(request, new NotImplementedError(request.kind)); } op = std; argSchema = OP_SCHEMAS[std].args; resultSchema = OP_SCHEMAS[std].result; const handler = config[std]; if (handler === undefined) { return makeErrorEnvelope(request, new NotImplementedError(std)); } } // 2. Args validation (wire shape) const argParse = argSchema.safeParse(request.args); if (!argParse.success) { const issue = argParse.error.issues[0]; return makeErrorEnvelope( request, new InvalidArgsError( issue?.message ?? 'invalid arguments', issue?.path.join('.') || undefined, ), ); } const parsedArgs = argParse.data as unknown; // 2b. Custom op resolution let resolvedOpKind: string = op; if (op === 'custom') { const customArgs = parsedArgs as CustomArgs; customHandler = customRegistrations[customArgs.name]; if (customHandler === undefined) { return makeErrorEnvelope( request, new NotImplementedError(`custom:${customArgs.name}`), ); } resolvedOpKind = `custom:${customArgs.name}`; // Validate inner payload against the custom op's args schema const payloadParse = customHandler.args.safeParse(customArgs.payload); if (!payloadParse.success) { const issue = payloadParse.error.issues[0]; return makeErrorEnvelope( request, new InvalidArgsError( issue?.message ?? 'invalid custom-op payload', issue?.path.join('.') || undefined, ), ); } // Replace payload with validated value (Zod may apply defaults). (parsedArgs as CustomArgs).payload = payloadParse.data; } span.setAttribute(ATTR_OP, resolvedOpKind); // 3. Path validation (skip ops without a path) let primaryPath = ''; if (op === 'move') { primaryPath = (parsedArgs as MoveArgs).src; } else if (op !== 'custom' && 'path' in (parsedArgs as object)) { primaryPath = (parsedArgs as { path: string }).path; } let normalizedPath = primaryPath; if (primaryPath !== '') { const validated = validatePath(primaryPath, config.pathPolicy); if (!validated.ok) { return makeErrorEnvelope(request, new PathValidationError(validated.reason)); } normalizedPath = validated.normalized; if (op === 'move') { const dstValid = validatePath((parsedArgs as MoveArgs).dst, config.pathPolicy); if (!dstValid.ok) { return makeErrorEnvelope( request, new PathValidationError(dstValid.reason, 'dst'), ); } (parsedArgs as MoveArgs).src = normalizedPath; (parsedArgs as MoveArgs).dst = dstValid.normalized; } else { (parsedArgs as { path: string }).path = normalizedPath; } } // 4. Rate-limit acquire const opCostKey = op === 'custom' ? 'custom' : op; const customCost = customHandler?.cost; const estimatedBytes = estimateBytes(op === 'custom' ? 'custom' : op, parsedArgs); try { if (customCost !== undefined) { // Custom op-specific cost: acquire that many op-tokens manually. // Fall back to standard `custom` bucket cost for non-overridden. rateLimiter.acquire(from, opCostKey, estimatedBytes); // For overridden cost > 1, deduct extra tokens from the same bucket. // The simplest route: re-acquire (cost - 1) more. for (let i = 1; i < customCost; i++) { rateLimiter.acquire(from, opCostKey, 0); } } else { rateLimiter.acquire(from, opCostKey, estimatedBytes); } } catch (err) { metrics(METRIC_RATE_LIMIT_REJECT_TOTAL, 1, { op: resolvedOpKind }); return makeErrorEnvelope(request, err); } // 5. Idempotency (mutations only) const isMutation = MUTATION_OPS.has(opCostKey); let commitIdem: ((response: unknown) => void) | null = null; let abandonIdem: (() => void) | null = null; if (isMutation && request.idempotencyKey !== undefined) { try { const begin = idempotency.begin(from, request.idempotencyKey, parsedArgs); if (begin.status === 'replay') { rateLimiter.release(from, opCostKey, estimatedBytes); metrics(METRIC_IDEMPOTENCY_HIT_TOTAL, 1, { op: resolvedOpKind }); return makeResponseEnvelope(request, begin.response); } if (begin.status === 'wait') { const cached = await begin.promise; rateLimiter.release(from, opCostKey, estimatedBytes); metrics(METRIC_IDEMPOTENCY_HIT_TOTAL, 1, { op: resolvedOpKind }); return makeResponseEnvelope(request, cached); } commitIdem = begin.commit; abandonIdem = begin.abandon; } catch (err) { rateLimiter.release(from, opCostKey, estimatedBytes); if ( err !== null && typeof err === 'object' && (err as { code?: string }).code === 'SHADE_FS_IDEMPOTENCY_CONFLICT' ) { metrics(METRIC_IDEMPOTENCY_CONFLICT_TOTAL, 1, { op: resolvedOpKind }); } return makeErrorEnvelope(request, err); } } // 6. Build context + abort controller const controller = new AbortController(); inflightCancellers.set(request.id, controller); const ctx = buildOpContext({ op: op === 'custom' ? (resolvedOpKind as `custom:${string}`) : op, path: normalizedPath, parsedArgs, sender: from, signal: controller.signal, idempotencyKey: request.idempotencyKey, attemptNumber: request.attempt ?? 1, shade, }); // 7. Fingerprint gate if (config.requireFingerprintVerifiedFor !== undefined) { let gate: 'required' | 'optional' | 'reject'; try { gate = await config.requireFingerprintVerifiedFor(ctx as OpContext); } catch (err) { cleanup({ release: true }); return makeErrorEnvelope(request, err); } if (gate === 'reject') { cleanup({ release: true }); metrics(METRIC_FINGERPRINT_REJECT_TOTAL, 1, { op: resolvedOpKind, gate: 'reject' }); return makeErrorEnvelope( request, new FingerprintRequiredError('operation rejected by fingerprint policy'), ); } if (gate === 'required') { let verified = false; try { verified = config.isFingerprintVerified !== undefined ? Boolean(await config.isFingerprintVerified(from)) : false; } catch (err) { cleanup({ release: true }); return makeErrorEnvelope(request, err); } if (!verified) { cleanup({ release: true }); metrics(METRIC_FINGERPRINT_REJECT_TOTAL, 1, { op: resolvedOpKind, gate: 'required' }); return makeErrorEnvelope(request, new FingerprintRequiredError()); } } } // 8. beforeOp try { if (config.beforeOp !== undefined) { await config.beforeOp(ctx as OpContext); } } catch (err) { cleanup({ release: true }); return makeErrorEnvelope(request, err); } // 9. Run handler with timeout race — adapting I/O ops as needed. const timeoutMs = Math.min( request.deadlineMs ?? defaultTimeoutMs, defaultTimeoutMs, ); let wireResult: unknown; const startedAt = Date.now(); try { wireResult = await runWithTimeout( () => invokeOpHandler({ op, stdHandler: op === 'custom' ? undefined : (config[op] as unknown), customHandler, ctx: ctx as OpContext, parsedArgs, sender: from, signal: controller.signal, streamsBridge: config.streamsBridge, ioTimeoutMs, }), timeoutMs, controller, ); } catch (err) { const durationMs = Date.now() - startedAt; metrics(METRIC_OP_DURATION_MS, durationMs, { op: resolvedOpKind, result: 'error' }); metrics(METRIC_OP_TOTAL, 1, { op: resolvedOpKind, result: 'error' }); span.setAttribute(ATTR_RESULT, 'error'); span.setAttribute(ATTR_ERROR_CODE, errorCodeOf(err)); span.recordException(err); span.setStatus('error'); cleanup({ release: true }); if (config.onError !== undefined) { try { config.onError(err, ctx as OpContext); } catch (hookErr) { console.error('[FileHandler] onError hook threw:', hookErr); } } return makeErrorEnvelope(request, err); } // 10. Custom-op response validation against the registered schema. if (op === 'custom' && customHandler !== undefined) { const innerParse = customHandler.response.safeParse(wireResult); if (!innerParse.success) { cleanup({ release: true }); return makeErrorEnvelope( request, new CustomOpRejectedError( `custom-op response shape rejected by registered schema: ${innerParse.error.issues[0]?.message ?? 'unknown'}`, ), ); } wireResult = { result: innerParse.data }; } // 11. Defensive response validation against the wire schema. const resultParse = resultSchema.safeParse(wireResult); if (!resultParse.success) { cleanup({ release: true }); return makeErrorEnvelope( request, new InvalidArgsError( `handler for ${resolvedOpKind} returned invalid response shape`, ), ); } // 12. afterOp try { if (config.afterOp !== undefined) { await config.afterOp(ctx as OpContext, resultParse.data); } } catch (err) { cleanup({ release: true }); return makeErrorEnvelope(request, err); } // 13. Commit idempotency + emit metrics commitIdem?.(resultParse.data); cleanup({ release: false }); const durationMs = Date.now() - startedAt; metrics(METRIC_OP_DURATION_MS, durationMs, { op: resolvedOpKind, result: 'ok' }); metrics(METRIC_OP_TOTAL, 1, { op: resolvedOpKind, result: 'ok' }); span.setAttribute(ATTR_RESULT, 'ok'); if (estimatedBytes > 0) { // Inbound bytes (write) vs outbound (read) — both reuse the same // pre-call `estimatedBytes`, since post-execution reconciliation // would require deeper plumbing. const direction = op === 'write' ? METRIC_BYTES_IN : op === 'read' ? METRIC_BYTES_OUT : null; if (direction !== null) { metrics(direction, estimatedBytes, { op: resolvedOpKind }); } span.setAttribute(ATTR_BYTES_BIN, bytesBin(estimatedBytes)); } span.setStatus('ok'); return makeResponseEnvelope(request, resultParse.data); function cleanup(opts: { release: boolean }): void { inflightCancellers.delete(request.id); if (opts.release) { abandonIdem?.(); rateLimiter.release(from, opCostKey, estimatedBytes); } } } function handleCancel(_from: string, cancel: RpcCancel): void { const controller = inflightCancellers.get(cancel.id); if (controller !== undefined) { controller.abort(new CancelledError(cancel.reason ?? 'cancelled by sender')); inflightCancellers.delete(cancel.id); } } function destroy(): void { for (const c of inflightCancellers.values()) { c.abort(new CancelledError('handler destroyed')); } inflightCancellers.clear(); } return Object.assign({ handleRequest, handleCancel, destroy }, { [INTERNAL_SYMBOL]: { idempotency, rateLimiter }, }); } export const INTERNAL_SYMBOL = Symbol.for('@shade/files/internal'); // ─── Op invoker (handles I/O adapters) ─────────────────────── interface InvokeArgs { op: StandardOp | 'custom'; stdHandler: unknown; customHandler: CustomOpRegistrations[string] | undefined; ctx: OpContext; parsedArgs: unknown; sender: string; signal: AbortSignal; streamsBridge: ServerStreamsBridge | undefined; ioTimeoutMs: number; } async function invokeOpHandler(args: InvokeArgs): Promise { const { op, stdHandler, customHandler, ctx, parsedArgs, sender, signal, streamsBridge, ioTimeoutMs } = args; const adapterDeps = { streamsBridge, sender, signal, ioTimeoutMs }; switch (op) { case 'write': { const wireArgs = parsedArgs as WriteArgs; const { userArgs, awaitTransferDone } = await adaptWriteArgs(wireArgs, adapterDeps); const userCtx = { ...ctx, args: userArgs } as OpContext; const userResult = await (stdHandler as (c: OpContext) => Promise)(userCtx); await awaitTransferDone(); return userResult; } case 'read': { const readArgs = parsedArgs as ReadArgs; const userResult = await (stdHandler as (c: OpContext) => Promise)(ctx as OpContext); return await adaptReadResult(userResult, readArgs, adapterDeps); } case 'getThumbnail': { const userResult = await (stdHandler as (c: OpContext) => Promise)(ctx as OpContext); return adaptThumbnailResult(userResult); } case 'custom': { if (customHandler === undefined) { throw new NotImplementedError('custom op without registration'); } const customArgs = parsedArgs as CustomArgs; const innerCtx = { ...ctx, args: customArgs } as OpContext<{ name: string; payload: unknown }>; // Pass the validated inner payload as the first arg, the OpContext as the second. return await customHandler.handler( customArgs.payload, innerCtx as OpContext<{ name: string; payload: unknown }>, ); } default: // Pass-through for list/stat/mkdir/delete/move. return await (stdHandler as (c: OpContext) => Promise)(ctx); } } // ─── Helpers ───────────────────────────────────────────────── function makeResponseEnvelope(req: RpcRequest, result: unknown): RpcResponse { return { kind: responseKindOf(req.kind), id: req.id, result, }; } function errorCodeOf(err: unknown): string { if (err === null || err === undefined) return 'SHADE_UNKNOWN'; if (typeof err === 'object') { const code = (err as { code?: unknown }).code; if (typeof code === 'string' && code.length > 0) return code; const name = (err as { name?: unknown }).name; if (typeof name === 'string' && name.length > 0) return name; } return 'SHADE_UNKNOWN'; } function makeErrorEnvelope(req: RpcRequest, err: unknown): RpcError { return { kind: 'shade.fs.error/v1', id: req.id, error: payloadFromError(err), }; } function estimateBytes(op: StandardOp | 'custom', args: unknown): number { if (op === 'write') { const w = args as { kind: 'inline' | 'streams'; bytesB64?: string; size?: number }; if (w.kind === 'inline' && typeof w.bytesB64 === 'string') { return Math.floor((w.bytesB64.length * 3) / 4); } return w.size ?? 0; } if (op === 'read') { const r = args as { range?: { start: number; end: number } }; if (r.range !== undefined) return r.range.end - r.range.start; return 0; } return 0; } async function runWithTimeout( fn: () => Promise, timeoutMs: number, controller: AbortController, ): Promise { let timer: ReturnType | null = null; const timeout = new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(new Error('timeout')); reject(new (FileError as unknown as { new (p: { code: string; message: string }): FileError })({ code: 'OPERATION_TIMEOUT', message: `operation timed out after ${timeoutMs}ms`, })); }, timeoutMs); }); try { return await Promise.race([fn(), timeout]); } finally { if (timer !== null) clearTimeout(timer); } }