diff --git a/sdk-typescript/packages/kernel/package.json b/sdk-typescript/packages/kernel/package.json index 54cbf2b..ab5a6ff 100644 --- a/sdk-typescript/packages/kernel/package.json +++ b/sdk-typescript/packages/kernel/package.json @@ -66,10 +66,13 @@ "node": ">=18.0.0" }, "dependencies": { - "@onkernel/sdk": "^0.78.0" + "@noble/ed25519": "^3.1.0", + "@onkernel/sdk": "^0.78.0", + "opossum": "^10.0.0" }, "devDependencies": { "@types/node": "^25.0.3", + "@types/opossum": "^8.1.9", "tsup": "^8.0.0", "typescript": "^5.9.0", "vitest": "^4.1.4" diff --git a/sdk-typescript/packages/kernel/src/__tests__/circuit-breaker.test.ts b/sdk-typescript/packages/kernel/src/__tests__/circuit-breaker.test.ts new file mode 100644 index 0000000..1f05483 --- /dev/null +++ b/sdk-typescript/packages/kernel/src/__tests__/circuit-breaker.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { + createCircuitBreaker, + OpossumCircuitBreaker, +} from '../circuit-breaker.js'; +import { CircuitOpenError, KernelErrorCode } from '../errors.js'; +import type { KernelBreadcrumb } from '../resilience.js'; + +describe('createCircuitBreaker', () => { + it('returns an OpossumCircuitBreaker', () => { + expect(createCircuitBreaker()).toBeInstanceOf(OpossumCircuitBreaker); + }); + + it('passes through the action result while closed', async () => { + const breaker = createCircuitBreaker(); + await expect(breaker.fire(async () => 42)).resolves.toBe(42); + expect(breaker.opened).toBe(false); + }); + + it('propagates action errors while closed', async () => { + // High volumeThreshold so a single failure does not trip the breaker. + const breaker = createCircuitBreaker({ volumeThreshold: 100 }); + await expect( + breaker.fire(async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + }); + + it('opens after failures and rejects fast with CircuitOpenError', async () => { + const breadcrumbs: KernelBreadcrumb[] = []; + const breaker = createCircuitBreaker({ + volumeThreshold: 1, + errorThresholdPercentage: 1, + resetTimeout: 10_000, + captureBreadcrumb: (b) => breadcrumbs.push(b), + }); + + await expect( + breaker.fire(async () => { + throw new Error('fail'); + }), + ).rejects.toThrow('fail'); + + expect(breaker.opened).toBe(true); + + const err = await breaker.fire(async () => 1).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CircuitOpenError); + expect((err as CircuitOpenError).code).toBe(KernelErrorCode.CIRCUIT_OPEN); + + const open = breadcrumbs.find((b) => b.data?.state === 'open'); + expect(open).toBeDefined(); + expect(open?.category).toBe('kernel'); + expect(open?.level).toBe('warning'); + }); + + it('names breadcrumbs and the circuit-open error after serviceName', async () => { + const breadcrumbs: KernelBreadcrumb[] = []; + const breaker = createCircuitBreaker({ + serviceName: 'kernel-edge', + volumeThreshold: 1, + errorThresholdPercentage: 1, + captureBreadcrumb: (b) => breadcrumbs.push(b), + }); + await breaker + .fire(async () => { + throw new Error('x'); + }) + .catch(() => undefined); + const err = await breaker.fire(async () => 1).catch((e: unknown) => e); + expect((err as CircuitOpenError).context).toEqual({ + serviceName: 'kernel-edge', + }); + expect(breadcrumbs[0]?.category).toBe('kernel-edge'); + }); +}); diff --git a/sdk-typescript/packages/kernel/src/__tests__/client.test.ts b/sdk-typescript/packages/kernel/src/__tests__/client.test.ts index cc2d24a..3de0e31 100644 --- a/sdk-typescript/packages/kernel/src/__tests__/client.test.ts +++ b/sdk-typescript/packages/kernel/src/__tests__/client.test.ts @@ -7,6 +7,10 @@ import { isKernelApiError, } from '../errors.js'; import { withTelemetry, WAVE_TELEMETRY_DEFAULT } from '../telemetry.js'; +import { createCircuitBreaker } from '../circuit-breaker.js'; +import { createWebBotAuthSigner } from '../web-bot-auth.js'; + +const TEST_KEY_HEX = '00'.repeat(31) + '07'; describe('WaveKernel', () => { it('constructs with an explicit apiKey without throwing (no network)', () => { @@ -58,6 +62,44 @@ describe('WaveKernel', () => { }); }); +describe('WaveKernel resilience wiring', () => { + it('run() executes directly when no breaker is configured (inert)', async () => { + const kernel = new WaveKernel({ apiKey: 'x' }); + await expect(kernel.run(async () => 'ok')).resolves.toBe('ok'); + }); + + it('run() routes through the breaker and reports errors via captureError', async () => { + const errors: unknown[] = []; + const breaker = createCircuitBreaker({ volumeThreshold: 100 }); + const kernel = new WaveKernel({ + apiKey: 'x', + resilience: { breaker, captureError: (e) => errors.push(e) }, + }); + await expect( + kernel.run(async () => { + throw new Error('nope'); + }), + ).rejects.toThrow('nope'); + expect(errors).toHaveLength(1); + }); + + it('constructs with a signer wired in without throwing (no network)', () => { + const signer = createWebBotAuthSigner({ + privateKey: TEST_KEY_HEX, + keyId: 'k1', + }); + expect( + () => new WaveKernel({ apiKey: 'x', resilience: { signer } }), + ).not.toThrow(); + }); + + it('exposes the resilience hooks it was constructed with', () => { + const breaker = createCircuitBreaker(); + const kernel = new WaveKernel({ apiKey: 'x', resilience: { breaker } }); + expect(kernel.resilience?.breaker).toBe(breaker); + }); +}); + describe('withTelemetry', () => { it('enables console/network/page and leaves screenshot undefined', () => { const params = withTelemetry({}); diff --git a/sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts b/sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts new file mode 100644 index 0000000..004701b --- /dev/null +++ b/sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; +import * as ed25519 from '@noble/ed25519'; +import { + createWebBotAuthSigner, + WebBotAuthSigner, +} from '../web-bot-auth.js'; + +const KEY_HEX = '00'.repeat(31) + '07'; +const FIXED_NOW = () => 1_700_000_000_000; + +/** + * Independently rebuild the RFC-9421 signature base a spec-compliant verifier + * would construct — driven ONLY by the produced `Signature-Input` header and + * the request, NOT by the signer's own base builder. Parses the covered + * component list (in header order), recomputes each derived component value + * from the request per RFC-9421, and appends the verbatim `@signature-params`. + */ +function reconstructBase( + req: { method: string; url: string; signatureAgent?: string }, + signatureInput: string, + label = 'sig1', +): string { + const params = signatureInput.replace(new RegExp(`^${label}=`), ''); + const inner = params.slice(params.indexOf('(') + 1, params.indexOf(')')); + const ids = inner.length + ? inner.split(' ').map((t) => t.replace(/^"|"$/g, '')) + : []; + const url = new URL(req.url); + const method = req.method.toUpperCase(); + const valueFor = (id: string): string => { + switch (id) { + case '@method': + return method; + case '@authority': + return url.host; + case '@path': + return url.pathname; + case '@query': + return url.search || '?'; + case '@target-uri': + return url.href; + case 'signature-agent': + return req.signatureAgent ?? ''; + default: + throw new Error(`test reconstruct: unhandled component "${id}"`); + } + }; + const lines = ids.map((id) => `"${id}": ${valueFor(id)}`); + lines.push(`"@signature-params": ${params}`); + return lines.join('\n'); +} + +async function verifySigned( + req: { method: string; url: string; signatureAgent?: string }, + out: Record, + pub: Uint8Array, +): Promise { + const base = reconstructBase(req, out['Signature-Input']); + const sigB64 = out['Signature'].replace(/^sig1=:/, '').replace(/:$/, ''); + const sigBytes = Uint8Array.from(Buffer.from(sigB64, 'base64')); + return ed25519.verifyAsync(sigBytes, new TextEncoder().encode(base), pub); +} + +describe('createWebBotAuthSigner', () => { + it('returns a WebBotAuthSigner', () => { + expect( + createWebBotAuthSigner({ privateKey: KEY_HEX, keyId: 'k1' }), + ).toBeInstanceOf(WebBotAuthSigner); + }); + + it('produces RFC-9421 Signature and Signature-Input headers', async () => { + const signer = createWebBotAuthSigner({ + privateKey: KEY_HEX, + keyId: 'k1', + now: FIXED_NOW, + }); + const out = await signer.sign({ + method: 'get', + url: 'https://api.onkernel.com/browsers?x=1', + headers: {}, + }); + expect(out['Signature']).toMatch(/^sig1=:.+:$/); + expect(out['Signature-Input']).toContain('keyid="k1"'); + expect(out['Signature-Input']).toContain('alg="ed25519"'); + expect(out['Signature-Input']).toContain('created=1700000000'); + expect(out['Signature-Input']).toContain('expires=1700000060'); + // Default covered set now carries @query as a distinct component. + expect(out['Signature-Input']).toContain( + '"@authority" "@method" "@path" "@query"', + ); + expect(out['Signature-Agent']).toBeUndefined(); + }); + + it('includes and covers Signature-Agent when configured', async () => { + const signer = createWebBotAuthSigner({ + privateKey: KEY_HEX, + keyId: 'k1', + signatureAgent: '"https://wave.online"', + }); + const out = await signer.sign({ + method: 'GET', + url: 'https://api.onkernel.com/', + headers: {}, + }); + expect(out['Signature-Agent']).toBe('"https://wave.online"'); + expect(out['Signature-Input']).toContain('"signature-agent"'); + }); + + it('signs a URL WITH query params so a real verifier reconstructs a byte-identical base', async () => { + const priv = ed25519.etc.hexToBytes(KEY_HEX); + const pub = await ed25519.getPublicKeyAsync(priv); + const signer = createWebBotAuthSigner({ + privateKey: priv, + keyId: 'k1', + now: FIXED_NOW, + }); + const req = { + method: 'GET', + url: 'https://api.onkernel.com/browsers?foo=bar&baz=1', + }; + const out = await signer.sign({ ...req, headers: {} }); + + // @path must be path-only; @query must carry the full query separately. + const base = reconstructBase(req, out['Signature-Input']); + expect(base).toContain('"@path": /browsers\n'); + expect(base).toContain('"@query": ?foo=bar&baz=1'); + expect(base).not.toContain('/browsers?foo=bar'); + + expect(await verifySigned(req, out, pub)).toBe(true); + }); + + it('signs a URL with NO query: @path is bare path and @query is "?"', async () => { + const priv = ed25519.etc.hexToBytes(KEY_HEX); + const pub = await ed25519.getPublicKeyAsync(priv); + const signer = createWebBotAuthSigner({ + privateKey: priv, + keyId: 'k1', + now: FIXED_NOW, + }); + const req = { method: 'POST', url: 'https://api.onkernel.com/v1/x' }; + const out = await signer.sign({ ...req, headers: {} }); + + const base = reconstructBase(req, out['Signature-Input']); + expect(base).toContain('"@path": /v1/x\n'); + expect(base).toContain('"@query": ?\n'); + + expect(await verifySigned(req, out, pub)).toBe(true); + }); + + it('fails open: returns {} and reports via captureError on invalid input', async () => { + const errors: unknown[] = []; + const signer = createWebBotAuthSigner({ + privateKey: KEY_HEX, + keyId: 'k1', + captureError: (e) => errors.push(e), + }); + const out = await signer.sign({ + method: 'GET', + url: 'not-a-valid-url', + headers: {}, + }); + expect(out).toEqual({}); + expect(errors).toHaveLength(1); + }); +}); diff --git a/sdk-typescript/packages/kernel/src/circuit-breaker.ts b/sdk-typescript/packages/kernel/src/circuit-breaker.ts new file mode 100644 index 0000000..34ebd49 --- /dev/null +++ b/sdk-typescript/packages/kernel/src/circuit-breaker.ts @@ -0,0 +1,144 @@ +/** + * Circuit breaker for Kernel calls — concrete impl of the {@link CircuitBreakerLike} + * seam, backed by opossum. + * + * The breaker is OPT-IN: construct one and inject it via `resilience.breaker`. + * When absent, the client behaves exactly like the plain SDK wrapper. + * + * Params mirror WAVE's prior internal Kernel client: + * errorThresholdPercentage 50 · resetTimeout 30000 · rollingCountTimeout + * 60000 · volumeThreshold 5. + * + * On open / halfOpen / close the breaker emits a breadcrumb through the + * INJECTED {@link CaptureBreadcrumb} hook — never a hard error-reporter import — + * so this stays framework-agnostic (usable from non-Next hosts). + */ +import CircuitBreaker from 'opossum'; +import { KernelApiError, type CircuitOpenError } from './errors.js'; +import type { CaptureBreadcrumb, CircuitBreakerLike } from './resilience.js'; + +/** Options for {@link createCircuitBreaker}. Defaults match the WAVE reference. */ +export interface CircuitBreakerOptions { + /** Failure percentage that trips the breaker. Default 50. */ + errorThresholdPercentage?: number; + /** Time (ms) the breaker stays open before a trial (half-open) call. Default 30000. */ + resetTimeout?: number; + /** Rolling statistics window (ms) for failure counting. Default 60000. */ + rollingCountTimeout?: number; + /** Minimum requests in the window before the breaker can trip. Default 5. */ + volumeThreshold?: number; + /** + * Per-call timeout (ms) enforced by the breaker, or `false` to disable. + * Defaults to `false` — the underlying SDK already owns request timeouts, so + * the breaker does not impose a second one unless asked. + */ + timeoutMs?: number | false; + /** Service name used in breadcrumbs and the circuit-open error. Default `'kernel'`. */ + serviceName?: string; + /** Injected sink for open/halfOpen/close breadcrumbs. */ + captureBreadcrumb?: CaptureBreadcrumb; +} + +/** opossum's rejection code when a call is shed because the circuit is open. */ +const OPEN_BREAKER_CODE = 'EOPENBREAKER'; + +function isOpenBreakerError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === OPEN_BREAKER_CODE + ); +} + +/** + * opossum-backed {@link CircuitBreakerLike}. A single breaker guards the whole + * Kernel call path: every action runs through one shared rolling window, so + * failures across different SDK calls trip the breaker together. + */ +export class OpossumCircuitBreaker implements CircuitBreakerLike { + private readonly breaker: CircuitBreaker<[() => Promise], unknown>; + private readonly serviceName: string; + + constructor(options: CircuitBreakerOptions = {}) { + this.serviceName = options.serviceName ?? 'kernel'; + + this.breaker = new CircuitBreaker( + (action: () => Promise) => action(), + { + errorThresholdPercentage: options.errorThresholdPercentage ?? 50, + resetTimeout: options.resetTimeout ?? 30_000, + rollingCountTimeout: options.rollingCountTimeout ?? 60_000, + volumeThreshold: options.volumeThreshold ?? 5, + timeout: options.timeoutMs ?? false, + name: this.serviceName, + }, + ); + + const breadcrumb = options.captureBreadcrumb; + if (breadcrumb) { + this.breaker.on('open', () => + breadcrumb({ + category: this.serviceName, + message: 'Circuit breaker opened', + level: 'warning', + data: { state: 'open' }, + }), + ); + this.breaker.on('halfOpen', () => + breadcrumb({ + category: this.serviceName, + message: 'Circuit breaker half-open', + level: 'info', + data: { state: 'halfOpen' }, + }), + ); + this.breaker.on('close', () => + breadcrumb({ + category: this.serviceName, + message: 'Circuit breaker closed', + level: 'info', + data: { state: 'close' }, + }), + ); + } + } + + /** Whether the breaker is currently open (shedding requests). */ + get opened(): boolean { + return this.breaker.opened; + } + + /** + * Run `action` through the breaker. While open it rejects fast with a + * {@link CircuitOpenError} rather than opossum's internal open-breaker error. + */ + async fire(action: () => Promise): Promise { + try { + return (await this.breaker.fire(action)) as T; + } catch (error) { + if (isOpenBreakerError(error)) { + throw KernelApiError.circuitOpen(this.serviceName) as CircuitOpenError; + } + throw error; + } + } +} + +/** + * Build an opossum-backed circuit breaker implementing {@link CircuitBreakerLike}. + * Inject the result via `new WaveKernel({ resilience: { breaker } })`. + * + * @example + * ```typescript + * const breaker = createCircuitBreaker({ + * captureBreadcrumb: (b) => Sentry.addBreadcrumb(b), + * }); + * const kernel = new WaveKernel({ apiKey, resilience: { breaker } }); + * await kernel.run(() => kernel.browsers.create({})); + * ``` + */ +export function createCircuitBreaker( + options: CircuitBreakerOptions = {}, +): CircuitBreakerLike { + return new OpossumCircuitBreaker(options); +} diff --git a/sdk-typescript/packages/kernel/src/client.ts b/sdk-typescript/packages/kernel/src/client.ts index 1a89bf2..3c5d38a 100644 --- a/sdk-typescript/packages/kernel/src/client.ts +++ b/sdk-typescript/packages/kernel/src/client.ts @@ -19,7 +19,13 @@ * ``` */ import { Kernel } from '@onkernel/sdk'; -import type { ResilienceHooks } from './resilience.js'; +import type { ResilienceHooks, SignerLike } from './resilience.js'; + +/** A `fetch`-compatible function, as accepted by the underlying SDK. */ +type FetchLike = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; /** Default request timeout in milliseconds. */ export const DEFAULT_TIMEOUT_MS = 30_000; @@ -35,8 +41,9 @@ export interface WaveKernelConfig { /** Request timeout in milliseconds. Defaults to {@link DEFAULT_TIMEOUT_MS}. */ timeoutMs?: number; /** - * Optional resilience hooks (circuit breaker, signer). Inert in slice 1 — - * see {@link ResilienceHooks} and `src/resilience.ts`. + * Optional resilience hooks (circuit breaker, request signer, observability + * capture). Every field is optional and inert by default — see + * {@link ResilienceHooks}, `createCircuitBreaker`, and `createWebBotAuthSigner`. */ resilience?: ResilienceHooks; } @@ -54,6 +61,7 @@ export class WaveKernel { constructor(config: WaveKernelConfig = {}) { this.resilience = config.resilience; + const signer = this.resilience?.signer; this.kernel = new Kernel({ apiKey: config.apiKey ?? process.env.KERNEL_API_KEY, // Only override baseURL when explicitly provided so the SDK default @@ -61,9 +69,67 @@ export class WaveKernel { ...(config.baseURL !== undefined ? { baseURL: config.baseURL } : {}), ...(config.projectID !== undefined ? { projectID: config.projectID } : {}), timeout: config.timeoutMs ?? DEFAULT_TIMEOUT_MS, + // Wire the WebBotAuth signer into the SDK's request path only when a + // signer is configured. Absent = the SDK uses global fetch, unchanged. + ...(signer ? { fetch: this.buildSigningFetch(signer) } : {}), }); } + /** + * Run an SDK action through the configured circuit breaker. When no breaker + * is wired the action runs directly (behavior unchanged). Errors surfaced by + * the breaker are reported through the injected `captureError` hook. + * + * @example + * ```typescript + * const browser = await kernel.run(() => kernel.browsers.create({})); + * ``` + */ + async run(action: () => Promise): Promise { + const breaker = this.resilience?.breaker; + if (!breaker) return action(); + try { + return await breaker.fire(action); + } catch (error) { + this.resilience?.captureError?.(error, { service: 'kernel' }); + throw error; + } + } + + /** + * Build a `fetch` wrapper that adds WebBotAuth signature headers to every + * outbound SDK request. Fail-open: any signing error is captured and the + * request proceeds unsigned (the signer itself also fails open). + */ + private buildSigningFetch(signer: SignerLike): FetchLike { + return async (input, init) => { + const headers = new Headers(init?.headers); + try { + const method = (init?.method ?? 'GET').toUpperCase(); + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url; + const record: Record = {}; + headers.forEach((value, key) => { + record[key] = value; + }); + const signed = await signer.sign({ method, url, headers: record }); + for (const [key, value] of Object.entries(signed)) { + headers.set(key, value); + } + } catch (error) { + this.resilience?.captureError?.(error, { + service: 'kernel', + component: 'web-bot-auth', + }); + } + return globalThis.fetch(input, { ...init, headers }); + }; + } + /** Escape hatch to the underlying SDK client. Prefer the typed getters below. */ get raw(): Kernel { return this.kernel; diff --git a/sdk-typescript/packages/kernel/src/index.ts b/sdk-typescript/packages/kernel/src/index.ts index 40fe8ce..ee4d72c 100644 --- a/sdk-typescript/packages/kernel/src/index.ts +++ b/sdk-typescript/packages/kernel/src/index.ts @@ -40,9 +40,26 @@ export { type BrowserCreateParams, } from './telemetry.js'; -// Resilience seams (slice-2 wiring lands later) +// Resilience seams (contracts) export type { ResilienceHooks, CircuitBreakerLike, SignerLike, + CaptureError, + CaptureBreadcrumb, + KernelBreadcrumb, } from './resilience.js'; + +// Circuit breaker (opossum-backed, opt-in) +export { + createCircuitBreaker, + OpossumCircuitBreaker, + type CircuitBreakerOptions, +} from './circuit-breaker.js'; + +// WebBotAuth request signing (RFC-9421 Ed25519, fail-open, opt-in) +export { + createWebBotAuthSigner, + WebBotAuthSigner, + type WebBotAuthConfig, +} from './web-bot-auth.js'; diff --git a/sdk-typescript/packages/kernel/src/resilience.ts b/sdk-typescript/packages/kernel/src/resilience.ts index 6a17737..74e0c56 100644 --- a/sdk-typescript/packages/kernel/src/resilience.ts +++ b/sdk-typescript/packages/kernel/src/resilience.ts @@ -1,21 +1,56 @@ /** - * Resilience seams — SLICE 1 provides interfaces only, no implementation. + * Resilience seams — the clean boundaries the WaveKernel constructor accepts. * - * These are the clean boundaries the WaveKernel constructor accepts so that - * slice 2 can drop in the real circuit breaker + signing without changing the - * public surface. Everything here is optional and inert in slice 1: the client - * typechecks and runs without any of it wired up. + * These interfaces let concrete resilience layers (circuit breaker, request + * signer, observability capture) drop in without changing the public surface. + * Everything here is optional and inert by default: the client typechecks and + * runs unchanged when none of it is wired up. * - * TODO(slice-2): port from WAVE's internal Kernel client — - * - opossum circuit breaker (errorThresholdPercentage: 50, resetTimeout: - * 30000, volumeThreshold: 5) - * - Sentry capture (tags: { service: 'kernel' }) - * - WebBotAuth Ed25519 request signing (RFC 9421, fail-open) + * Concrete implementations of these seams live alongside this module: + * - `createCircuitBreaker` (opossum-backed; errorThresholdPercentage 50, + * resetTimeout 30000, rollingCountTimeout 60000, volumeThreshold 5) + * - `createWebBotAuthSigner` (RFC-9421 Ed25519 request signing, fail-open) + * Observability is injected, not imported — see {@link CaptureError} / + * {@link CaptureBreadcrumb}. This package never hard-depends on any error + * reporter, so it stays usable from non-framework hosts. */ /** - * Minimal circuit-breaker contract. Slice 2 will back this with opossum - * (errorThresholdPercentage: 50, resetTimeout: 30000, volumeThreshold: 5). + * A structured breadcrumb describing a resilience event (e.g. a circuit-breaker + * state transition). The consumer maps this onto its own reporter (Sentry, + * OpenTelemetry, a logger). Mirrors the breadcrumb shape used by common error + * reporters without importing one. + */ +export interface KernelBreadcrumb { + /** Grouping category — always `'kernel'` for signals from this client. */ + category: string; + /** Human-readable message describing the event. */ + message: string; + /** Severity of the event. */ + level?: 'info' | 'warning' | 'error'; + /** Optional structured context (e.g. `{ state: 'open' }`). */ + data?: Record; +} + +/** + * Injected error-capture sink (e.g. wired to `Sentry.captureException`). + * Implementations SHOULD tag captures with `service: 'kernel'`. + */ +export type CaptureError = ( + error: unknown, + context?: Record, +) => void; + +/** + * Injected breadcrumb sink (e.g. wired to `Sentry.addBreadcrumb`). Receives + * resilience events such as circuit-breaker open/halfOpen/close transitions. + */ +export type CaptureBreadcrumb = (breadcrumb: KernelBreadcrumb) => void; + +/** + * Minimal circuit-breaker contract. Backed by opossum via + * `createCircuitBreaker` (errorThresholdPercentage 50, resetTimeout 30000, + * rollingCountTimeout 60000, volumeThreshold 5). */ export interface CircuitBreakerLike { /** Run `action` through the breaker; rejects fast with a circuit-open error while open. */ @@ -25,9 +60,9 @@ export interface CircuitBreakerLike { } /** - * WebBotAuth (RFC 9421 HTTP Message Signatures) signer contract. Slice 2 will - * back this with an Ed25519 signer that FAILS OPEN — a signing failure must - * never block a request. + * WebBotAuth (RFC 9421 HTTP Message Signatures) signer contract. Backed by an + * Ed25519 signer via `createWebBotAuthSigner` that FAILS OPEN — a signing + * failure must never block a request. */ export interface SignerLike { /** @@ -42,14 +77,17 @@ export interface SignerLike { } /** - * Optional resilience wiring accepted by the WaveKernel constructor. Inert in - * slice 1; every field is optional. + * Optional resilience wiring accepted by the WaveKernel constructor. Every + * field is optional and inert by default — a client constructed without any of + * these behaves exactly like the plain SDK wrapper. */ export interface ResilienceHooks { - /** Circuit breaker guarding Kernel calls. */ + /** Circuit breaker guarding Kernel calls (see `createCircuitBreaker`). */ breaker?: CircuitBreakerLike; - /** WebBotAuth request signer (fail-open). */ + /** WebBotAuth request signer, fail-open (see `createWebBotAuthSigner`). */ signer?: SignerLike; - /** Capture sink for errors (e.g. Sentry), tagged `service: 'kernel'` in slice 2. */ - captureError?: (error: unknown, context?: Record) => void; + /** Capture sink for errors (e.g. Sentry), tagged `service: 'kernel'`. */ + captureError?: CaptureError; + /** Breadcrumb sink for resilience events (e.g. breaker state transitions). */ + captureBreadcrumb?: CaptureBreadcrumb; } diff --git a/sdk-typescript/packages/kernel/src/web-bot-auth.ts b/sdk-typescript/packages/kernel/src/web-bot-auth.ts new file mode 100644 index 0000000..eb0aadf --- /dev/null +++ b/sdk-typescript/packages/kernel/src/web-bot-auth.ts @@ -0,0 +1,198 @@ +/** + * WebBotAuth request signer — concrete impl of the {@link SignerLike} seam. + * + * Produces RFC-9421 (HTTP Message Signatures) headers with an Ed25519 key. + * Signing is OPT-IN (only when a key is configured) and FAILS OPEN: any signing + * error is reported through the injected {@link CaptureError} hook and an empty + * header set is returned so the request proceeds unsigned. A signing failure + * must never block a request. + * + * Crypto is `@noble/ed25519` — a small, maintained, dependency-light library + * that runs on Node, browsers, and edge runtimes. No framework dependency. + */ +import * as ed25519 from '@noble/ed25519'; +import type { CaptureError, SignerLike } from './resilience.js'; + +/** Default signature validity window (60 seconds). */ +const DEFAULT_EXPIRES_IN_MS = 60_000; +/** Default signature label used in the `Signature` / `Signature-Input` headers. */ +const DEFAULT_LABEL = 'sig1'; +/** Default RFC-9421 `tag` parameter identifying the signature purpose. */ +const DEFAULT_TAG = 'web-bot-auth'; + +/** Configuration for {@link createWebBotAuthSigner}. */ +export interface WebBotAuthConfig { + /** + * Ed25519 private key — a 32-byte seed, as a `Uint8Array` or a hex string. + * Signing only happens when this is present (opt-in). + */ + privateKey: Uint8Array | string; + /** Key identifier surfaced as the `keyid` signature parameter. */ + keyId: string; + /** + * Structured-field value for the `Signature-Agent` header (e.g. + * `"https://wave.online"`). When set, it is emitted and covered by the + * signature. Pass the full structured-field value (quotes included). + */ + signatureAgent?: string; + /** Signature validity window in milliseconds. Default 60000. */ + expiresInMs?: number; + /** + * RFC-9421 covered component identifiers. Defaults to + * `['@authority', '@method', '@path', '@query']`, plus `'signature-agent'` + * when {@link signatureAgent} is set. + */ + coveredComponents?: string[]; + /** Signature label. Default `'sig1'`. */ + label?: string; + /** RFC-9421 `tag` parameter. Default `'web-bot-auth'`. */ + tag?: string; + /** Injected error sink; receives signing failures (fail-open). */ + captureError?: CaptureError; + /** Clock override for deterministic testing. Returns epoch milliseconds. */ + now?: () => number; +} + +function toBytes(key: Uint8Array | string): Uint8Array { + return typeof key === 'string' ? ed25519.etc.hexToBytes(key) : key; +} + +function base64(bytes: Uint8Array): string { + if (typeof Buffer !== 'undefined') { + return Buffer.from(bytes).toString('base64'); + } + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary); +} + +function base64url(bytes: Uint8Array): string { + return base64(bytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function randomNonce(): string { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return base64url(bytes); +} + +/** RFC-9421 Ed25519 request signer. Fails open on any error. */ +export class WebBotAuthSigner implements SignerLike { + private readonly privateKey: Uint8Array; + + constructor(private readonly config: WebBotAuthConfig) { + this.privateKey = toBytes(config.privateKey); + } + + /** + * Produce `Signature`, `Signature-Input` (and, when configured, + * `Signature-Agent`) headers for the request. Returns `{}` on failure. + */ + async sign(input: { + method: string; + url: string; + headers: Record; + }): Promise> { + try { + const url = new URL(input.url); + const method = input.method.toUpperCase(); + const agent = this.config.signatureAgent; + + const covered = + this.config.coveredComponents ?? + ['@authority', '@method', '@path', '@query', ...(agent ? ['signature-agent'] : [])]; + + const lowerHeaders: Record = {}; + for (const [k, v] of Object.entries(input.headers)) { + lowerHeaders[k.toLowerCase()] = v; + } + + const valueFor = (id: string): string | undefined => { + switch (id) { + case '@method': + return method; + case '@authority': + return url.host; + case '@path': + // RFC-9421 2.2.6: absolute path only; the query is a SEPARATE + // "@query" derived component and must never be glommed onto "@path". + return url.pathname; + case '@target-uri': + return url.href; + case '@query': + // RFC-9421 2.2.7: the full query string including the leading "?". + // When the query is absent, the value is the "?" character alone. + return url.search || '?'; + case 'signature-agent': + return agent; + default: + return lowerHeaders[id.toLowerCase()]; + } + }; + + const label = this.config.label ?? DEFAULT_LABEL; + const tag = this.config.tag ?? DEFAULT_TAG; + const now = this.config.now ?? Date.now; + const created = Math.floor(now() / 1000); + const expires = + created + + Math.floor((this.config.expiresInMs ?? DEFAULT_EXPIRES_IN_MS) / 1000); + const nonce = randomNonce(); + + const innerList = covered.map((id) => `"${id}"`).join(' '); + const params = + `(${innerList});created=${created};expires=${expires};` + + `keyid="${this.config.keyId}";alg="ed25519";nonce="${nonce}";tag="${tag}"`; + + const baseLines = covered.map((id) => { + const value = valueFor(id); + if (value === undefined) { + throw new Error(`Missing value for covered component "${id}"`); + } + return `"${id}": ${value}`; + }); + baseLines.push(`"@signature-params": ${params}`); + const signatureBase = baseLines.join('\n'); + + const signature = await ed25519.signAsync( + new TextEncoder().encode(signatureBase), + this.privateKey, + ); + + const headers: Record = { + 'Signature-Input': `${label}=${params}`, + Signature: `${label}=:${base64(signature)}:`, + }; + if (agent) headers['Signature-Agent'] = agent; + return headers; + } catch (error) { + this.config.captureError?.(error, { + service: 'kernel', + component: 'web-bot-auth', + }); + return {}; + } + } +} + +/** + * Build an Ed25519 WebBotAuth signer implementing {@link SignerLike}. Inject the + * result via `new WaveKernel({ resilience: { signer } })`; the client wires it + * into the SDK's request path. Signing is fail-open. + * + * @example + * ```typescript + * const signer = createWebBotAuthSigner({ + * privateKey: process.env.KERNEL_SIGNING_KEY_HEX, + * keyId: 'wave-2026', + * signatureAgent: '"https://wave.online"', + * }); + * const kernel = new WaveKernel({ apiKey, resilience: { signer } }); + * ``` + */ +export function createWebBotAuthSigner(config: WebBotAuthConfig): SignerLike { + return new WebBotAuthSigner(config); +} diff --git a/sdk-typescript/pnpm-lock.yaml b/sdk-typescript/pnpm-lock.yaml index f3be9af..92faf40 100644 --- a/sdk-typescript/pnpm-lock.yaml +++ b/sdk-typescript/pnpm-lock.yaml @@ -494,13 +494,22 @@ importers: packages/kernel: dependencies: + '@noble/ed25519': + specifier: ^3.1.0 + version: 3.1.0 '@onkernel/sdk': specifier: ^0.78.0 version: 0.78.0 + opossum: + specifier: ^10.0.0 + version: 10.0.0 devDependencies: '@types/node': specifier: ^25.0.3 version: 25.9.1 + '@types/opossum': + specifier: ^8.1.9 + version: 8.1.9 tsup: specifier: ^8.0.0 version: 8.5.1(postcss@8.5.15)(typescript@5.9.3) @@ -1405,6 +1414,9 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/ed25519@3.1.0': + resolution: {integrity: sha512-pfcObRY3CtvwfaG9Mt5XqZdKmAQppl37tHUeuBhDUbiwJBCVY4/A4lbMvb1xKhMDx96AqAqZpMWuBX1HulhX4g==} + '@onkernel/sdk@0.78.0': resolution: {integrity: sha512-VrGEDcuSwO6AKe6oYTNaQsAHnOAVeqehmStDTM0EFd3u8+WhITYxhU+jNFq+9yEt0N/nrn2COsk/ThQ+dxWBlw==} @@ -1656,6 +1668,9 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/opossum@8.1.9': + resolution: {integrity: sha512-Jm/tYxuJFefiwRYs+/EOsUP3ktk0c8siMgAHPLnA4PXF4wKghzcjqf88dY+Xii5jId5Txw4JV0FMKTpjbd7KJA==} + '@vitest/expect@4.1.7': resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} @@ -2111,6 +2126,10 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + opossum@10.0.0: + resolution: {integrity: sha512-sghtqL8Usj+et06Zui0nyn0R6FFsl7cyuoU+d7MctYU0nbS7Htzjleh+tWohFqk1Rp1srrFwbFa8Vxd0O64w6A==} + engines: {node: ^26 || ^24 || ^22} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2603,6 +2622,8 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@noble/ed25519@3.1.0': {} + '@onkernel/sdk@0.78.0': {} '@opentelemetry/api@1.9.1': {} @@ -2761,6 +2782,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/opossum@8.1.9': + dependencies: + '@types/node': 25.9.1 + '@vitest/expect@4.1.7': dependencies: '@standard-schema/spec': 1.1.0 @@ -3201,6 +3226,8 @@ snapshots: dependencies: wrappy: 1.0.2 + opossum@10.0.0: {} + parseurl@1.3.3: {} path-key@3.1.1: {}