release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -80,9 +80,13 @@ export function createFilesNamespace(shade: Shade): FilesNamespace {
if (state.serverBridge === null) {
state.serverBridge = await createServerStreamsBridge(shade);
}
const inheritedObservability = shade.getObservability?.();
const handler = createFileHandler(shade, {
...handlerConfig,
streamsBridge: state.serverBridge,
...(handlerConfig.observability === undefined && inheritedObservability !== undefined
? { observability: inheritedObservability }
: {}),
});
const detach = attachFileHandler(state.channel, handler);
state.serverHandler = handler;

View File

@@ -79,6 +79,17 @@ import {
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,
@@ -153,6 +164,12 @@ export interface FileHandlerConfig extends FileHandlerOps {
isFingerprintVerified?: (sender: string) => boolean | Promise<boolean>;
/** 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<unknown>) => void | Promise<void>;
/** Called AFTER the handler returns. Result is the validated response. */
@@ -207,12 +224,34 @@ export function createFileHandler(
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<RpcResponse | RpcError> {
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<RpcResponse | RpcError> {
// 0. Replay-window check (independent of sig — defends against
// intercept-and-resend even when sig verification is disabled).
@@ -316,6 +355,7 @@ export function createFileHandler(
// 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 = '';
@@ -488,6 +528,10 @@ export function createFileHandler(
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 {
@@ -543,6 +587,7 @@ export function createFileHandler(
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
@@ -551,7 +596,9 @@ export function createFileHandler(
if (direction !== null) {
metrics(direction, estimatedBytes, { op: resolvedOpKind });
}
span.setAttribute(ATTR_BYTES_BIN, bytesBin(estimatedBytes));
}
span.setStatus('ok');
return makeResponseEnvelope(request, resultParse.data);
@@ -654,6 +701,17 @@ function makeResponseEnvelope(req: RpcRequest, result: unknown): RpcResponse {
};
}
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',