Skip to content

[Phase 1] TypeScript SDK — Implement frame encoding with canonical request signing and test parity with Python SDK #34

@shayankashif123

Description

@shayankashif123

Problem

The TypeScript SDK (sdk/typescript/) exists as a documented skeleton but has zero implementation. All four source files contain
only JSDoc comments:

  • src/frame.ts — no functions implemented
  • src/transport.ts — no functions implemented
  • src/models.ts — no types defined
  • src/firewall.ts — no class implemented

More critically, now that canonical request signing has been implemented in the Python SDK and Go sidecar (#32), the TypeScript SDK must implement the same canonicalization from the start —
otherwise it will hit the exact HMAC mismatch bug that #32 was filed to fix.

The Canonicalization Problem for TypeScript

JavaScript's JSON.stringify does not sort keys by default:

// TypeScript/JavaScript — key order is insertion order
JSON.stringify({"b": 2, "a": 1})
// → '{"b":2,"a":1}'  ← NOT sorted

// Python SDK after #32 — sorted keys
json.dumps({"b": 2, "a": 1}, sort_keys=True)
// → '{"a":1,"b":2}'  ← sorted

If frame.ts signs with JSON.stringify without sorting, the
TypeScript SDK will produce a different HMAC than the Python SDK
for semantically identical payloads — the Go sidecar will reject
every TypeScript request with HMAC verification failed.

The fix must be built in from day one:

// Correct canonical serialization for TypeScript
function canonicalPayload(payload: Buffer): Buffer {
    const parsed = JSON.parse(payload.toString('utf-8'))
    const canonical = JSON.stringify(parsed, Object.keys(parsed).sort())
    return Buffer.from(canonical, 'utf-8')
}

Note: JSON.stringify with a sorted replacer array handles
top-level key sorting but does NOT recursively sort nested object
keys. A recursive sort is required to match Go and Python behaviour.

What Needs to Be Implemented

src/models.ts

  • Decision enum: ALLOW = 0x00, SANITISE = 0x01, BLOCK = 0x02
  • SanitiseResult interface matching Python SanitiseResult
  • ChunkResult interface matching Python ChunkResult
  • FirewallError and FirewallConnectionError classes

src/frame.ts

  • canonicalPayload(payload: Buffer): Buffer — recursive JSON
    key sort, compact encoding, rejects invalid JSON
  • signedMessage(version, length, nonce, payload): Buffer
    identical layout to Python/Go: version(1B) || length(4B BE)
    || nonce(16B) || canonical_payload
  • encodeRequest(payload: Buffer, key: Buffer): Buffer
  • decodeRequest(data: Buffer): RequestFrame
  • encodeResponse(decision, sanitised?): Buffer
  • decodeResponse(data: Buffer): ResponseFrame
  • FrameError and CanonicaliseError classes

src/transport.ts

  • Transport class with async send(payload: Buffer): Promise<ResponseFrame>
  • Unix Domain Socket via net.createConnection
  • Exponential backoff retry matching Python SDK (3 attempts,
    0.1s/0.2s/0.4s)
  • FirewallConnectionError after exhausting retries

src/firewall.ts

  • Firewall class with all four async hook methods
  • onPrompt(text: string): Promise<Decision | SanitiseResult>
  • onContext(chunks: string[]): Promise<ChunkResult[]>
  • onToolCall(name: string, params: Record<string, unknown>): Promise<Decision | SanitiseResult>
  • onMemory(key: string, value: string, op?: string): Promise<Decision | SanitiseResult>
  • HMAC key resolution from ACF_HMAC_KEY environment variable

Test Requirements

A test suite under sdk/typescript/tests/ using Node's built-in
test runner (already configured in package.json).

Minimum coverage required:

Frame tests (tests/frame.test.ts):

  • Magic byte, version, length field, nonce uniqueness
  • HMAC validity round-trip
  • Cross-SDK interop: fixed nonce + different key order → identical
    HMAC (the core canonicalization guarantee)
  • Canonical length in signed message vs raw input length
  • Unicode normalization: \u0041A (must match Go behaviour)
  • Invalid JSON rejected with CanonicaliseError
  • Recursive key sort for nested objects

Model tests (tests/models.test.ts):

  • Decision enum values match wire protocol bytes
  • FirewallConnectionError is subclass of FirewallError

Acceptance Criteria

  • All four src/ files fully implemented
  • canonicalPayload() recursively sorts keys at all nesting levels
  • Given the same nonce, TypeScript and Python produce identical
    HMAC signatures for semantically identical JSON
  • \u0041 Unicode escape normalizes to A — matching Go/Python
  • Invalid JSON rejected with a clear typed error
  • Test suite passes with npm test
  • Zero external dependencies — Node stdlib only
    (crypto, net, buffer)
  • Public API matches Python SDK hook signatures

Relates To

Notes on Recursive Key Sorting

The naive JSON.stringify(parsed, Object.keys(parsed).sort())
only sorts top-level keys. Nested objects are not sorted. A
recursive implementation is required:

function sortKeysRecursive(value: unknown): unknown {
    if (Array.isArray(value)) {
        return value.map(sortKeysRecursive)
    }
    if (value !== null && typeof value === 'object') {
        return Object.keys(value as object)
            .sort()
            .reduce((acc, key) => {
                acc[key] = sortKeysRecursive(
                    (value as Record<string, unknown>)[key]
                )
                return acc
            }, {} as Record<string, unknown>)
    }
    return value
}

function canonicalPayload(payload: Buffer): Buffer {
    let parsed: unknown
    try {
        parsed = JSON.parse(payload.toString('utf-8'))
    } catch (e) {
        throw new CanonicaliseError(
            `payload is not valid JSON: ${e}`
        )
    }
    const sorted   = sortKeysRecursive(parsed)
    const canonical = JSON.stringify(sorted)
    return Buffer.from(canonical, 'utf-8')
}

This matches the recursive behaviour of Go's encoding/json.Marshal
on map[string]interface{} and Python's json.dumps(sort_keys=True).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions