-
Notifications
You must be signed in to change notification settings - Fork 0
feat(kernel): resilience layers — breaker + injected observability + WebBotAuth signing (Phase A slice 2) #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}=`), ''); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Semgrep identified an issue in your code: Dataflow graphflowchart LR
classDef invis fill:white, stroke: none
classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none
subgraph File0["<b>sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts</b>"]
direction LR
%% Source
subgraph Source
direction LR
v0["<a href=https://github.com/wave-av/sdks/blob/7eab4af663bb1151efc7c9d1f8fbafa8894b86c5/sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts#L21 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 21] label</a>"]
end
%% Intermediate
subgraph Traces0[Traces]
direction TB
v2["<a href=https://github.com/wave-av/sdks/blob/7eab4af663bb1151efc7c9d1f8fbafa8894b86c5/sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts#L21 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 21] label</a>"]
v3["<a href=https://github.com/wave-av/sdks/blob/7eab4af663bb1151efc7c9d1f8fbafa8894b86c5/sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts#L23 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 23] `</a>"]
end
v2 --> v3
%% Sink
subgraph Sink
direction LR
v1["<a href=https://github.com/wave-av/sdks/blob/7eab4af663bb1151efc7c9d1f8fbafa8894b86c5/sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts#L23 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 23] new RegExp(`^${label}=`)</a>"]
end
end
%% Class Assignment
Source:::invis
Sink:::invis
Traces0:::invis
File0:::invis
%% Connections
Source --> Traces0
Traces0 --> Sink
To resolve this comment: 🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods. 💬 Ignore this findingReply with Semgrep commands to ignore this finding.
Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by detect-non-literal-regexp. You can view more details about this finding in the Semgrep AppSec Platform. |
||
| 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<string, string>, | ||
| pub: Uint8Array, | ||
| ): Promise<boolean> { | ||
| 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); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: wave-av/sdks
Length of output: 20928
🏁 Script executed:
Repository: wave-av/sdks
Length of output: 9458
🏁 Script executed:
Repository: wave-av/sdks
Length of output: 2788
🌐 Web query:
npm@noble/ed255193.1.0 engines node opossum 10.0.0 engines node💡 Result:
The Node.js engine requirements for the specified package versions are as follows: -
@noble/ed25519version 3.1.0 requires Node.js v20.19 or later [1][2][3]. - opossum version 10.0.0 requires Node.js v22 or later (Node.js >= 22) [4][5]. This version introduced a breaking change by removing support for Node.js 20 [6][7].Citations:
🌐 Web query:
site:npmjs.com opossum 10.0.0 engines node💡 Result:
The opossum npm package, which serves as a circuit breaker for Node.js, specifies its Node.js engine requirement as ">=22" in its metadata [1]. This means that the package is intended for use in environments running Node.js version 22 or newer [1]. Users should ensure their Node.js environment meets this requirement to avoid potential compatibility issues during installation or execution [2][3].
Citations:
🏁 Script executed:
Repository: wave-av/sdks
Length of output: 1943
Raise the kernel Node engine floor.
sdk-typescript/packages/kernel/package.json:65-71still saysnode: >=18.0.0, but@noble/ed25519@3.1.0needs Node 20.19+ andopossum@10.0.0needs Node 22+, so this package no longer matches its declared runtime support. Either pin older compatible versions or bump the documented engines/CI baseline.🤖 Prompt for AI Agents