Files
Shade/packages/shade-sdk/src/config.ts
Sterister f5f42fe557 release(v4.3.0): browser persistence via @shade/storage-indexeddb
Ship an official IndexedDB-backed StorageProvider so browser-based Shade
consumers persist identity, prekeys, sessions, retired identities,
peer-verification state and stream-resume rows across tab refresh and
browser restart. Closes the gap that forced browser apps onto
storage:"memory" (regenerated identity each load, orphaned device
records server-side).

- New package @shade/storage-indexeddb (4.3.0): full StorageProvider
  conformance, schema v1, idb-backed; bumpPeerIdentityVersion is wrapped
  in a single readwrite IDB transaction (atomic, vs SQLite's
  read-then-upsert race).
- @shade/sdk resolveStorage() accepts { type: 'indexeddb', dbName? } via
  dynamic import (lazy, optional dep — same pattern as
  @shade/storage-postgres). Named StorageSpec type now reused by
  ResolvedConfig.
- Tests: 16 new tests in shade-storage-indexeddb (StorageProvider
  surface + peer-verifications + full E2EE conversation surviving a
  simulated tab reload). Run on fake-indexeddb.
- Lockstep version bump 4.2.1 → 4.3.0 across all 25 packages.
- Publish scripts updated to include the new package.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:35:02 +02:00

213 lines
6.6 KiB
TypeScript

import type { StorageProvider } from '@shade/core';
import { ConfigurationError } from '@shade/core';
import type { ObservabilityHook } from '@shade/observability';
/**
* Shade SDK configuration.
*
* All fields have sane defaults except `prekeyServer` which is required
* for any networked usage.
*/
export interface ShadeConfig {
/** Required: base URL of the prekey server */
prekeyServer: string;
/**
* Storage backend. Accepts:
* - "memory" — in-memory only (lost on restart, good for tests)
* - "sqlite:/path/to/file.db" — SQLite backend
* - { type: 'postgres', url: 'postgres://...' } — PostgreSQL backend
* - { type: 'indexeddb', dbName?: 'my-app' } — browser IndexedDB
* - An explicit StorageProvider instance
*
* Default: "memory"
*/
storage?: StorageSpec;
/**
* Your address on the prekey server (e.g. "alice@example.com" or "device:abc123").
* If omitted (undefined), a random UUID is generated and persisted.
*/
address?: string | undefined;
/**
* Auto-replenish configuration. When the one-time prekey stock drops
* below `min`, the SDK will generate enough to reach `target` and
* upload them to the prekey server.
*
* Default: { min: 5, target: 20, intervalMs: 60_000 }
* Pass `false` to disable.
*/
autoReplenish?: { min: number; target: number; intervalMs: number } | false;
/**
* Auto identity rotation. Default OFF.
* Pass an interval string like '7d' or '30d' to enable periodic rotation.
*/
autoRotate?: false | '1d' | '7d' | '30d' | '90d';
/**
* Optional observer configuration. If provided, starts a Shade Observer
* endpoint for the dashboard.
*/
observer?: {
/** Bearer token for the observer. Must be at least 16 chars. */
token: string;
/** Port to listen on. If not set, observer is created but not served. */
port?: number;
/** Path prefix to mount under (default: "/shade-observer") */
basePath?: string;
};
/**
* Optional OpenTelemetry observability hook. Build with
* `withTracer(otelTracer)` from `@shade/observability`. Default: off.
*
* The hook propagates to the session manager (encrypt/decrypt spans),
* transfer engine (upload/download), and `@shade/files` (per-op).
* `withTracer()` is itself a no-op unless `SHADE_OTEL_ENABLED` is set,
* so leaving this configured in code stays free in production until
* the env-var flips it on.
*/
observability?: ObservabilityHook;
/**
* Optional Key-Transparency verifier (V3.12). When set, every
* `fetchBundle` validates the server's inclusion proof against the
* pinned `logPublicKey` and feeds the STH into a `LightWitness` for
* split-view detection.
*
* Modes:
* - `'observe'` — verify proofs when present, do not fail when missing.
* - `'observe-strict'` — require a proof on every successful and 404 response.
*
* Default: KT verification disabled. Set this to enable.
*/
keyTransparency?: ShadeKTConfig;
}
/** Public configuration shape for `Shade.keyTransparency`. */
export interface ShadeKTConfig {
mode: 'observe' | 'observe-strict';
/** Operator's pinned signing public key (32-byte Ed25519) — base64 or raw bytes. */
logPublicKey: Uint8Array | string;
/** Reject STHs older than this many ms. Default 24h. */
maxStaleMs?: number;
/** Cap on observed-STH cache. Default 1024. */
witnessMaxStored?: number;
}
/** Acceptable shapes for `ShadeConfig.storage`. */
export type StorageSpec =
| string
| StorageProvider
| { type: 'postgres'; url: string }
| { type: 'indexeddb'; dbName?: string };
export interface ResolvedConfig {
prekeyServer: string;
storage: StorageSpec;
address?: string | undefined;
autoReplenish: { min: number; target: number; intervalMs: number } | false;
autoRotate: false | '1d' | '7d' | '30d' | '90d';
observer?: {
token: string;
port?: number | undefined;
basePath: string;
};
observability?: ObservabilityHook;
keyTransparency?: ResolvedKTConfig;
}
export interface ResolvedKTConfig {
mode: 'observe' | 'observe-strict';
logPublicKey: Uint8Array;
maxStaleMs: number;
witnessMaxStored: number;
}
/** Parse and validate a ShadeConfig, resolving defaults and env var overrides */
export function resolveConfig(input: ShadeConfig): ResolvedConfig {
if (!input.prekeyServer) {
throw new ConfigurationError('prekeyServer is required');
}
const autoReplenish = input.autoReplenish === false
? false
: {
min: input.autoReplenish?.min ?? 5,
target: input.autoReplenish?.target ?? 20,
intervalMs: input.autoReplenish?.intervalMs ?? 60_000,
};
const resolved: ResolvedConfig = {
prekeyServer: input.prekeyServer.replace(/\/$/, ''),
storage: input.storage ?? 'memory',
address: input.address,
autoReplenish,
autoRotate: input.autoRotate ?? false,
};
if (input.observer) {
if (!input.observer.token || input.observer.token.length < 16) {
throw new ConfigurationError('observer.token must be at least 16 characters');
}
resolved.observer = {
token: input.observer.token,
port: input.observer.port,
basePath: input.observer.basePath ?? '/shade-observer',
};
}
if (input.observability !== undefined) {
resolved.observability = input.observability;
}
if (input.keyTransparency !== undefined) {
const kt = input.keyTransparency;
if (kt.mode !== 'observe' && kt.mode !== 'observe-strict') {
throw new ConfigurationError(
`keyTransparency.mode must be 'observe' or 'observe-strict'`,
);
}
let logKey: Uint8Array;
if (typeof kt.logPublicKey === 'string') {
try {
logKey = new Uint8Array(Buffer.from(kt.logPublicKey, 'base64'));
} catch {
throw new ConfigurationError(
'keyTransparency.logPublicKey must be base64 or Uint8Array',
);
}
} else {
logKey = kt.logPublicKey;
}
if (logKey.length !== 32) {
throw new ConfigurationError(
`keyTransparency.logPublicKey must be 32 bytes (got ${logKey.length})`,
);
}
resolved.keyTransparency = {
mode: kt.mode,
logPublicKey: logKey,
maxStaleMs: kt.maxStaleMs ?? 24 * 60 * 60 * 1000,
witnessMaxStored: kt.witnessMaxStored ?? 1024,
};
}
return resolved;
}
/** Parse a rotation interval string ('7d', '30d', etc.) into milliseconds */
export function parseRotationInterval(
interval: '1d' | '7d' | '30d' | '90d',
): number {
const map: Record<string, number> = {
'1d': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
'90d': 90 * 24 * 60 * 60 * 1000,
};
return map[interval]!;
}