From 65a7e649d73fd680cd05e6a7d7eb77c76ef97f32 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Mon, 4 May 2026 17:15:05 +0200 Subject: [PATCH 01/12] feat(kafka): default-private KAs with envelope selection in route adapter Route adapter at packages/cli/src/daemon/routes/kafka.ts now resolves the privacy posture (default: private: true) from the request body and wraps the bare KA in either {private: KA} or {public: KA} before calling agent.publish(). The response echoes the resolved `private` boolean. New daemon-routes-kafka.test.ts unit-tests all three envelope-selection cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/daemon/routes/kafka.ts | 16 +- packages/cli/test/daemon-routes-kafka.test.ts | 190 ++++++++++++++++++ 2 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 packages/cli/test/daemon-routes-kafka.test.ts diff --git a/packages/cli/src/daemon/routes/kafka.ts b/packages/cli/src/daemon/routes/kafka.ts index 550f6f05e..13404d5ec 100644 --- a/packages/cli/src/daemon/routes/kafka.ts +++ b/packages/cli/src/daemon/routes/kafka.ts @@ -32,6 +32,7 @@ export async function handleKafkaRoutes(ctx: RequestContext): Promise { broker, topic, messageFormat, + private: privateField, } = parsed as Record; if (!validateRequiredContextGraphId(contextGraphId, res)) { @@ -48,12 +49,17 @@ export async function handleKafkaRoutes(ctx: RequestContext): Promise { return jsonResponse(res, 400, { error: '"messageFormat" must be a non-empty string' }); } + // Default to private: true. Callers opt into a public KA by sending + // `private: false` in the request body. The envelope choice lives here + // in the adapter — the kafka package's publisher receives the bare KA. + const isPrivate = privateField !== false; + const publisher: KafkaEndpointPublisher = { async publish(cgId, content) { - await agent.publish( - cgId, - { public: content } as Record, - ); + const envelope = isPrivate + ? { private: content } + : { public: content }; + await agent.publish(cgId, envelope as Record); }, }; @@ -66,6 +72,6 @@ export async function handleKafkaRoutes(ctx: RequestContext): Promise { publisher, }); - return jsonResponse(res, 200, result); + return jsonResponse(res, 200, { ...result, private: isPrivate }); } } diff --git a/packages/cli/test/daemon-routes-kafka.test.ts b/packages/cli/test/daemon-routes-kafka.test.ts new file mode 100644 index 000000000..36403dc4f --- /dev/null +++ b/packages/cli/test/daemon-routes-kafka.test.ts @@ -0,0 +1,190 @@ +/** + * Unit tests for the Kafka route adapter's privacy-envelope logic. + * + * The route adapter (packages/cli/src/daemon/routes/kafka.ts) is responsible + * for wrapping the bare KA in either `{ private: KA }` or `{ public: KA }` + * before passing it to agent.publish(). The kafka package itself stays agnostic. + * + * These tests invoke handleKafkaRoutes directly with a minimal RequestContext + * mock — no real daemon, no network, no chain. + */ + +import { describe, it, expect } from 'vitest'; +import { EventEmitter } from 'node:events'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { handleKafkaRoutes } from '../src/daemon/routes/kafka.js'; +import type { RequestContext } from '../src/daemon/routes/context.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Make a minimal fake IncomingMessage for POST /api/kafka/endpoint with the + * given JSON body. + * + * readBody() uses req.on('data' | 'end' | 'error') so we simulate an + * EventEmitter that emits those events synchronously after the first tick. + */ +function makeFakeRequest(body: object): IncomingMessage { + const bodyStr = JSON.stringify(body); + const emitter = new EventEmitter() as unknown as IncomingMessage; + emitter.method = 'POST'; + emitter.url = '/api/kafka/endpoint'; + + // Emit data/end on the next tick so listeners are attached first + setImmediate(() => { + emitter.emit('data', Buffer.from(bodyStr)); + emitter.emit('end'); + }); + + return emitter; +} + +/** + * Make a fake ServerResponse that captures the status and JSON body. + * + * jsonResponse() calls writeHead(status, headers) then end(body), so we + * must accept the (status, headers?) overload of writeHead. + */ +function makeFakeResponse(): { res: ServerResponse; getResult: () => { status: number; body: unknown } } { + let capturedStatus = 0; + let capturedBody: unknown = null; + + const res = new EventEmitter() as unknown as ServerResponse; + (res as any).writeHead = (status: number, _headers?: unknown) => { + capturedStatus = status; + return res; + }; + (res as any).end = (data?: string) => { + try { + capturedBody = data ? JSON.parse(data) : null; + } catch { + capturedBody = data; + } + return res; + }; + + return { + res, + getResult: () => ({ status: capturedStatus, body: capturedBody }), + }; +} + +/** + * Build a minimal RequestContext with a mock agent.publish that captures calls. + */ +function makeContext(req: IncomingMessage, res: ServerResponse): { + ctx: RequestContext; + publishCalls: Array<{ cgId: string; envelope: unknown }>; +} { + const publishCalls: Array<{ cgId: string; envelope: unknown }> = []; + + const mockAgent = { + async publish(cgId: string, envelope: unknown) { + publishCalls.push({ cgId, envelope }); + return { ual: 'did:dkg:test/1', kcId: '1', status: 'confirmed' as const }; + }, + }; + + const ctx: Partial = { + req, + res, + agent: mockAgent as unknown as RequestContext['agent'], + requestAgentAddress: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + path: '/api/kafka/endpoint', + }; + + return { ctx: ctx as RequestContext, publishCalls }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const VALID_BASE_BODY = { + contextGraphId: 'devnet-test', + broker: 'kafka.example.com:9092', + topic: 'orders.created', + messageFormat: 'application/json', +}; + +describe('Kafka route adapter — privacy envelope', () => { + it('wraps with { private: KA } when private: true is in request body', async () => { + const req = makeFakeRequest({ ...VALID_BASE_BODY, private: true }); + const { res, getResult } = makeFakeResponse(); + const { ctx, publishCalls } = makeContext(req, res); + + await handleKafkaRoutes(ctx); + + const { status, body } = getResult(); + expect(status).toBe(200); + + expect(publishCalls).toHaveLength(1); + const { envelope } = publishCalls[0]!; + expect(envelope).toHaveProperty('private'); + expect(envelope).not.toHaveProperty('public'); + + // Response must echo the resolved private flag + expect((body as Record).private).toBe(true); + }); + + it('wraps with { public: KA } when private: false is in request body', async () => { + const req = makeFakeRequest({ ...VALID_BASE_BODY, private: false }); + const { res, getResult } = makeFakeResponse(); + const { ctx, publishCalls } = makeContext(req, res); + + await handleKafkaRoutes(ctx); + + const { status, body } = getResult(); + expect(status).toBe(200); + + expect(publishCalls).toHaveLength(1); + const { envelope } = publishCalls[0]!; + expect(envelope).toHaveProperty('public'); + expect(envelope).not.toHaveProperty('private'); + + // Response must echo the resolved private flag + expect((body as Record).private).toBe(false); + }); + + it('defaults to { private: KA } when private field is omitted from request body', async () => { + // No `private` field — route defaults to private: true + const req = makeFakeRequest(VALID_BASE_BODY); + const { res, getResult } = makeFakeResponse(); + const { ctx, publishCalls } = makeContext(req, res); + + await handleKafkaRoutes(ctx); + + const { status, body } = getResult(); + expect(status).toBe(200); + + expect(publishCalls).toHaveLength(1); + const { envelope } = publishCalls[0]!; + expect(envelope).toHaveProperty('private'); + expect(envelope).not.toHaveProperty('public'); + + // Response echoes resolved private flag = true (default) + expect((body as Record).private).toBe(true); + }); + + it('returns 400 when contextGraphId is missing', async () => { + const req = makeFakeRequest({ broker: 'x:9092', topic: 't', messageFormat: 'application/json' }); + const { res, getResult } = makeFakeResponse(); + const { ctx } = makeContext(req, res); + + await handleKafkaRoutes(ctx); + + expect(getResult().status).toBe(400); + }); + + it('returns 400 when broker is missing', async () => { + const req = makeFakeRequest({ contextGraphId: 'devnet-test', topic: 't', messageFormat: 'application/json' }); + const { res, getResult } = makeFakeResponse(); + const { ctx } = makeContext(req, res); + + await handleKafkaRoutes(ctx); + + expect(getResult().status).toBe(400); + }); +}); From d1a2c6eb59929f8047dd5ecea631ef314c3f4d2e Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Mon, 4 May 2026 17:18:21 +0200 Subject: [PATCH 02/12] feat(cli): add --public flag and privacy echo to kafka endpoint register - api-client.registerKafkaEndpoint accepts optional private?: boolean and returns private: boolean in the response - CLI adds --public flag (default is private; --public flips to private: false) - CLI stdout now prints 'Private: ' for unambiguous confirmation - Smoke tests updated: assert default omits private field (route defaults to private), --public sends private: false, and stdout echoes resolved value Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/api-client.ts | 2 + packages/cli/src/cli.ts | 3 ++ packages/cli/test/kafka-cli-smoke.test.ts | 47 ++++++++++++++++++++++- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/api-client.ts b/packages/cli/src/api-client.ts index d51715b38..36840f6f6 100644 --- a/packages/cli/src/api-client.ts +++ b/packages/cli/src/api-client.ts @@ -556,9 +556,11 @@ export class ApiClient { broker: string; topic: string; messageFormat: string; + private?: boolean; }): Promise<{ uri: string; contextGraphId: string; + private: boolean; }> { return this.post('/api/kafka/endpoint', request); } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 0e55ea5de..5020ac1d5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1732,6 +1732,7 @@ kafkaEndpointCmd .requiredOption('--broker ', 'Kafka broker host:port') .requiredOption('--topic ', 'Kafka topic name') .option('--format ', 'Kafka message format MIME type', 'application/json') + .option('--public', 'Publish the endpoint as a public KA (default: private, encrypted to CG participants)') .action(async (opts: ActionOpts) => { try { const client = await ApiClient.connect(); @@ -1740,10 +1741,12 @@ kafkaEndpointCmd broker: opts.broker, topic: opts.topic, messageFormat: opts.format, + ...(opts.public ? { private: false } : {}), }); console.log('Kafka endpoint registered:'); console.log(` URI: ${result.uri}`); console.log(` Context graph: ${result.contextGraphId}`); + console.log(` Private: ${result.private}`); } catch (err) { console.error(toErrorMessage(err)); process.exit(1); diff --git a/packages/cli/test/kafka-cli-smoke.test.ts b/packages/cli/test/kafka-cli-smoke.test.ts index f80ec60b2..6ce38a88a 100644 --- a/packages/cli/test/kafka-cli-smoke.test.ts +++ b/packages/cli/test/kafka-cli-smoke.test.ts @@ -18,6 +18,8 @@ describe.sequential('kafka CLI smoke', () => { let smokeApiPort: string; let lastBody = ''; let lastAuthHeader = ''; + // Track what privacy flag the server should echo back in its response + let serverPrivate = true; beforeAll(async () => { dkgHome = await mkdtemp(join(tmpdir(), 'dkg-kafka-cli-')); @@ -38,10 +40,14 @@ describe.sequential('kafka CLI smoke', () => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } lastBody = Buffer.concat(chunks).toString('utf8'); + const requestedPrivate = (JSON.parse(lastBody) as Record).private; + // Mirror what the real route does: omitted → private, false → public + serverPrivate = requestedPrivate !== false; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ uri: 'urn:dkg:kafka-endpoint:0xabc:hash', contextGraphId: 'devnet-test', + private: serverPrivate, })); return; } @@ -65,7 +71,7 @@ describe.sequential('kafka CLI smoke', () => { await rm(dkgHome, { recursive: true, force: true }); }); - it('registers a Kafka endpoint through the CLI', async () => { + it('registers a Kafka endpoint (default: private) through the CLI', async () => { const env = { ...process.env, DKG_HOME: dkgHome, DKG_API_PORT: smokeApiPort }; const result = await execFileAsync('node', [ @@ -84,12 +90,49 @@ describe.sequential('kafka CLI smoke', () => { expect(result.stdout).toContain('Kafka endpoint registered:'); expect(result.stdout).toContain('urn:dkg:kafka-endpoint:0xabc:hash'); expect(result.stdout).toContain('devnet-test'); + // Default (no --public flag) → private: true displayed in stdout + expect(result.stdout).toContain('Private: true'); expect(lastAuthHeader).toBe('Bearer smoke-token'); - expect(JSON.parse(lastBody)).toEqual({ + + // No --public flag: `private` field is OMITTED from the request body + // (route defaults to private when the field is absent). + const parsedBody = JSON.parse(lastBody) as Record; + expect(parsedBody).toEqual({ contextGraphId: 'devnet-test', broker: 'kafka.example.com:9092', topic: 'orders.created', messageFormat: 'application/json', }); + expect(parsedBody).not.toHaveProperty('private'); + }, 15000); + + it('registers a public Kafka endpoint when --public flag is provided', async () => { + const env = { ...process.env, DKG_HOME: dkgHome, DKG_API_PORT: smokeApiPort }; + + const result = await execFileAsync('node', [ + CLI_ENTRY, + 'kafka', + 'endpoint', + 'register', + '--cg', + 'devnet-test', + '--broker', + 'kafka.example.com:9092', + '--topic', + 'public-orders', + '--public', + ], { env }); + + expect(result.stdout).toContain('Kafka endpoint registered:'); + // --public flag → private: false displayed in stdout + expect(result.stdout).toContain('Private: false'); + expect(lastAuthHeader).toBe('Bearer smoke-token'); + + // --public flag: `private: false` sent in request body + const parsedBody = JSON.parse(lastBody) as Record; + expect(parsedBody.private).toBe(false); + expect(parsedBody.contextGraphId).toBe('devnet-test'); + expect(parsedBody.broker).toBe('kafka.example.com:9092'); + expect(parsedBody.topic).toBe('public-orders'); }, 15000); }); From 58254118f3fe7dd2a163e289f8dd557acb370955 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Mon, 4 May 2026 17:19:08 +0200 Subject: [PATCH 03/12] test(kafka): add private and public e2e scenarios to walking-skeleton test Split the single e2e scenario into: - public (--public): asserts stdout shows 'Private: false' and SPARQL returns KA - private (default): asserts stdout shows 'Private: true' and HTTP 200; SPARQL behavior for encrypted KAs is documented as out-of-scope for single-node devnet (encryption-to-CG-participants requires multi-node setup) Both tests are gated on DKG_KAFKA_E2E=1 and skip by default in CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../kafka/test/e2e/walking-skeleton.test.ts | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/kafka/test/e2e/walking-skeleton.test.ts b/packages/kafka/test/e2e/walking-skeleton.test.ts index 9cca516be..5b4e09fa1 100644 --- a/packages/kafka/test/e2e/walking-skeleton.test.ts +++ b/packages/kafka/test/e2e/walking-skeleton.test.ts @@ -141,7 +141,9 @@ describe('kafka walking skeleton e2e', () => { if (!RUN_E2E || !devnetReachable) skip(); }); - it('registers a Kafka endpoint into the named context graph and discovers it via SPARQL', async () => { + it('registers a public Kafka endpoint into the named context graph and discovers it via SPARQL', async () => { + // Registers with --public so the KA is wrapped in { public: KA } and + // cleartext quads are stored. SPARQL query should return the endpoint row. const broker = 'kafka.e2e.local:9092'; const topic = `walking-skeleton.${Date.now()}`; const messageFormat = 'application/cloudevents+json'; @@ -162,6 +164,7 @@ describe('kafka walking skeleton e2e', () => { topic, '--format', messageFormat, + '--public', ], { cwd: REPO_ROOT, @@ -176,6 +179,8 @@ describe('kafka walking skeleton e2e', () => { expect(result.stdout).toContain('Kafka endpoint registered:'); expect(result.stdout).toContain(expectedUri); expect(result.stdout).toContain(CONTEXT_GRAPH_ID); + // --public flag → response echoes Private: false + expect(result.stdout).toContain('Private: false'); const row = await waitForEndpointRow(client, CONTEXT_GRAPH_ID, expectedUri); @@ -186,4 +191,58 @@ describe('kafka walking skeleton e2e', () => { expect(stripIriDelimiters(row.endpointUrl ?? '')).toBe(`kafka://${broker}/${topic}`); expect(Number.isNaN(Date.parse(stripQuotedLiteral(row.issued ?? '')))).toBe(false); }, 90_000); + + it('registers a private (default) Kafka endpoint and confirms CLI reports Private: true', async () => { + // Registers WITHOUT --public so the KA is wrapped in { private: KA } and + // stored as encrypted data for CG participants. + // + // V10 private-KA semantics: on a single-node devnet the local participant + // IS the publisher, so the node may decrypt the KA for itself and surface + // it via SPARQL. On a multi-node network, other participants would need + // decryption keys. We do NOT assert SPARQL content here because: + // 1. Verifying encryption-to-CG-participants requires a full multi-node + // devnet with participant key management — beyond this single-node e2e. + // 2. The route-adapter unit tests in daemon-routes-kafka.test.ts already + // confirm { private: KA } is the envelope sent to agent.publish(). + // This e2e test focuses on: + // (a) CLI stdout reports Private: true + // (b) the request was accepted (HTTP 200 → execFileAsync doesn't throw) + const broker = 'kafka.e2e.private:9092'; + const topic = `walking-skeleton-private.${Date.now()}`; + const messageFormat = 'application/cloudevents+json'; + + const result = await execFileAsync( + 'node', + [ + CLI_ENTRY, + 'kafka', + 'endpoint', + 'register', + '--cg', + CONTEXT_GRAPH_ID, + '--broker', + broker, + '--topic', + topic, + '--format', + messageFormat, + // NO --public flag: default is private + ], + { + cwd: REPO_ROOT, + env: { + ...process.env, + DKG_HOME: DEVNET_NODE1_HOME, + DKG_API_PORT: String(port), + }, + }, + ); + + expect(result.stdout).toContain('Kafka endpoint registered:'); + expect(result.stdout).toContain(CONTEXT_GRAPH_ID); + // Default (no --public) → response echoes Private: true + expect(result.stdout).toContain('Private: true'); + // stdout does NOT say "Private: false" + expect(result.stdout).not.toContain('Private: false'); + }, 90_000); }); From fa4160011a14fa529eaf5323253917103ca410f1 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Mon, 4 May 2026 17:33:45 +0200 Subject: [PATCH 04/12] test(cli): align registerKafkaEndpoint mock with default-private response shape The api-client.test.ts mock for registerKafkaEndpoint omitted the `private` field from the response body. Since the api-client return type now declares `private: boolean` as required, the mock no longer matched the real wire shape. Adding `private: true` (the default-private posture matching this test's omitted-field request) keeps the fixture in sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/test/api-client.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/test/api-client.test.ts b/packages/cli/test/api-client.test.ts index 7a6cfff20..04dcef3e3 100644 --- a/packages/cli/test/api-client.test.ts +++ b/packages/cli/test/api-client.test.ts @@ -201,6 +201,10 @@ describe('ApiClient', () => { body: { uri: 'urn:dkg:kafka-endpoint:0xabc:hash', contextGraphId: 'devnet-test', + // Mirror the real route's response shape: it always echoes the + // resolved `private` flag (default-private when omitted from the + // request body, as is the case here). + private: true, }, }); globalThis.fetch = fetch; From 279269989c649dc3fbf40915975c7a98c67e674d Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Mon, 4 May 2026 17:34:47 +0200 Subject: [PATCH 05/12] fix(kafka): reject non-boolean "private" field with 400 and document leniency Previously the route silently coerced non-boolean values for `private` (e.g. the string "false") to private via the `!== false` predicate. Now the route explicitly rejects non-booleans with 400, matching the strict type-checking already applied to broker/topic/messageFormat in the same handler. The remaining `!== false` predicate is preserved for the omitted-defaults-to-private semantic, with a comment warning future readers not to "tighten" it to `=== true` (which would silently break the default). Adds a rejection-case unit test in daemon-routes-kafka.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/daemon/routes/kafka.ts | 16 ++++++++++++++-- packages/cli/test/daemon-routes-kafka.test.ts | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/daemon/routes/kafka.ts b/packages/cli/src/daemon/routes/kafka.ts index 13404d5ec..a35ac2e69 100644 --- a/packages/cli/src/daemon/routes/kafka.ts +++ b/packages/cli/src/daemon/routes/kafka.ts @@ -49,9 +49,21 @@ export async function handleKafkaRoutes(ctx: RequestContext): Promise { return jsonResponse(res, 400, { error: '"messageFormat" must be a non-empty string' }); } + // Privacy boundary: enforce a strict boolean to keep the contract + // unambiguous. Mirrors the strict typing applied above to + // broker/topic/messageFormat — string "false", numbers, etc. are + // rejected rather than silently coerced. + if (privateField !== undefined && typeof privateField !== 'boolean') { + return jsonResponse(res, 400, { error: '"private" must be a boolean' }); + } + // Default to private: true. Callers opt into a public KA by sending - // `private: false` in the request body. The envelope choice lives here - // in the adapter — the kafka package's publisher receives the bare KA. + // `private: false` in the request body. The `!== false` predicate is + // intentional: only the literal boolean `false` flips to public; any + // other accepted value (omitted/undefined, after the type-check above) + // resolves to private. This is the safe failure mode for a + // privacy-sensitive boundary — a future "tightening" to `=== true` + // would silently break the omitted-defaults-to-private semantic. const isPrivate = privateField !== false; const publisher: KafkaEndpointPublisher = { diff --git a/packages/cli/test/daemon-routes-kafka.test.ts b/packages/cli/test/daemon-routes-kafka.test.ts index 36403dc4f..68dd15679 100644 --- a/packages/cli/test/daemon-routes-kafka.test.ts +++ b/packages/cli/test/daemon-routes-kafka.test.ts @@ -187,4 +187,21 @@ describe('Kafka route adapter — privacy envelope', () => { expect(getResult().status).toBe(400); }); + + it('returns 400 when "private" is a non-boolean value (e.g. string "false")', async () => { + // The route enforces a strict boolean for `private` to keep the privacy + // contract unambiguous. Truthy/falsy coercion would create an unsafe + // ambiguity at a privacy boundary. + const req = makeFakeRequest({ ...VALID_BASE_BODY, private: 'false' }); + const { res, getResult } = makeFakeResponse(); + const { ctx, publishCalls } = makeContext(req, res); + + await handleKafkaRoutes(ctx); + + const { status, body } = getResult(); + expect(status).toBe(400); + expect((body as Record).error).toMatch(/"private" must be a boolean/); + // No publish should have happened + expect(publishCalls).toHaveLength(0); + }); }); From b93d866c854a25c77672a651e74c186dade9894e Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Mon, 4 May 2026 21:10:57 +0200 Subject: [PATCH 06/12] refactor(daemon): promote isNonEmptyString to shared http-utils The kafka route had a local `isNonEmptyString` guard that future routes (slice 02, slice 07) will need. Moves it to http-utils.ts next to the other validators so the wider daemon can reuse a single guard rather than copy-pasting the predicate. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/daemon/http-utils.ts | 10 ++++++++++ packages/cli/src/daemon/routes/kafka.ts | 11 ++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/daemon/http-utils.ts b/packages/cli/src/daemon/http-utils.ts index 462c71a57..73847a336 100644 --- a/packages/cli/src/daemon/http-utils.ts +++ b/packages/cli/src/daemon/http-utils.ts @@ -260,6 +260,16 @@ export function validateOptionalSubGraphName( return true; } +/** + * Generic guard: narrows `unknown` to a non-empty trimmed string. Useful in + * route handlers that need to validate string fields without coupling each + * one to a domain-specific validator. Does NOT write a response — caller + * decides the error semantics. + */ +export function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + export function validateRequiredContextGraphId( contextGraphId: unknown, res: ServerResponse, diff --git a/packages/cli/src/daemon/routes/kafka.ts b/packages/cli/src/daemon/routes/kafka.ts index a35ac2e69..af4a7e9d4 100644 --- a/packages/cli/src/daemon/routes/kafka.ts +++ b/packages/cli/src/daemon/routes/kafka.ts @@ -1,14 +1,15 @@ -import { jsonResponse, readBody, validateRequiredContextGraphId } from '../http-utils.js'; +import { + isNonEmptyString, + jsonResponse, + readBody, + validateRequiredContextGraphId, +} from '../http-utils.js'; import type { RequestContext } from './context.js'; import { registerKafkaEndpoint, type KafkaEndpointPublisher, } from '@origintrail-official/dkg-kafka'; -function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0; -} - export async function handleKafkaRoutes(ctx: RequestContext): Promise { const { req, From eab48b4894afd57367e19551e8ced53b0bac3b41 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Mon, 4 May 2026 21:12:38 +0200 Subject: [PATCH 07/12] refactor(daemon): extract wrapJsonLdContent envelope helper The kafka route adapter built the { public }/{ private } envelope inline with an `as Record` cast. The same pattern recurs in epcis.ts and is the load-bearing publish-time decision for slice 07's subscription work. Extracts the wrap into a small typed helper so: - The cast at the call site disappears (the helper returns JsonLdContent, which is exactly what DKGAgent.publish() accepts). - Future routes adopt the helper instead of re-deriving envelope shape. - The privacy semantics live in one documented location. Adds JsonLdContent / JsonLdDocument re-exports to the agent package barrel so consumers don't reach into deep import paths. Adds a 4-case unit test for the helper. epcis.ts is intentionally left untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/agent/src/index.ts | 1 + packages/cli/src/daemon/json-ld-envelope.ts | 17 ++++++ packages/cli/src/daemon/routes/kafka.ts | 6 +- .../cli/test/daemon-json-ld-envelope.test.ts | 55 +++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/daemon/json-ld-envelope.ts create mode 100644 packages/cli/test/daemon-json-ld-envelope.test.ts diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index cf0917df6..e28e11883 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -65,6 +65,7 @@ export { type PeerHealth, } from './dkg-agent.js'; export type { CclPublishedEvaluationRecord, CclPublishedResultEntry } from './dkg-agent.js'; +export type { JsonLdContent, JsonLdDocument } from './dkg-agent-utils.js'; export { bindRandomSampling, type RandomSamplingBindOptions, diff --git a/packages/cli/src/daemon/json-ld-envelope.ts b/packages/cli/src/daemon/json-ld-envelope.ts new file mode 100644 index 000000000..c41518442 --- /dev/null +++ b/packages/cli/src/daemon/json-ld-envelope.ts @@ -0,0 +1,17 @@ +import type { JsonLdContent, JsonLdDocument } from '@origintrail-official/dkg-agent'; + +/** + * Wrap a JSON-LD document in the `{ public }` or `{ private }` envelope + * shape that DKGAgent.publish() expects. + * + * The envelope choice is the privacy boundary: `{ private: ... }` is + * encrypted to context-graph participants per V10's private-KA flow; + * `{ public: ... }` is cleartext. See packages/agent/src/dkg-agent.ts + * publish() overloads for the contract. + */ +export function wrapJsonLdContent( + content: JsonLdDocument, + options: { private: boolean }, +): JsonLdContent { + return options.private ? { private: content } : { public: content }; +} diff --git a/packages/cli/src/daemon/routes/kafka.ts b/packages/cli/src/daemon/routes/kafka.ts index af4a7e9d4..8e40dfe4f 100644 --- a/packages/cli/src/daemon/routes/kafka.ts +++ b/packages/cli/src/daemon/routes/kafka.ts @@ -4,6 +4,7 @@ import { readBody, validateRequiredContextGraphId, } from '../http-utils.js'; +import { wrapJsonLdContent } from '../json-ld-envelope.js'; import type { RequestContext } from './context.js'; import { registerKafkaEndpoint, @@ -69,10 +70,7 @@ export async function handleKafkaRoutes(ctx: RequestContext): Promise { const publisher: KafkaEndpointPublisher = { async publish(cgId, content) { - const envelope = isPrivate - ? { private: content } - : { public: content }; - await agent.publish(cgId, envelope as Record); + await agent.publish(cgId, wrapJsonLdContent(content, { private: isPrivate })); }, }; diff --git a/packages/cli/test/daemon-json-ld-envelope.test.ts b/packages/cli/test/daemon-json-ld-envelope.test.ts new file mode 100644 index 000000000..e18663c19 --- /dev/null +++ b/packages/cli/test/daemon-json-ld-envelope.test.ts @@ -0,0 +1,55 @@ +/** + * Unit tests for `wrapJsonLdContent` — the small helper that produces the + * `{ public }` / `{ private }` envelope shape DKGAgent.publish() expects. + * + * The helper is the privacy boundary for any route that publishes a + * JSON-LD KA. Keeping it tiny and well-tested means slice-02/07 (and any + * future route that publishes a KA) can adopt it without re-deriving + * envelope semantics. + */ + +import { describe, expect, it } from 'vitest'; +import { wrapJsonLdContent } from '../src/daemon/json-ld-envelope.js'; + +describe('wrapJsonLdContent', () => { + it('wraps the document in { private: ... } when options.private is true', () => { + const doc = { '@id': 'urn:test:1', 'foo:bar': 'baz' }; + + const envelope = wrapJsonLdContent(doc, { private: true }); + + expect(envelope).toEqual({ private: doc }); + expect(envelope).not.toHaveProperty('public'); + }); + + it('wraps the document in { public: ... } when options.private is false', () => { + const doc = { '@id': 'urn:test:2', 'foo:bar': 'qux' }; + + const envelope = wrapJsonLdContent(doc, { private: false }); + + expect(envelope).toEqual({ public: doc }); + expect(envelope).not.toHaveProperty('private'); + }); + + it('preserves the document by reference (no copy)', () => { + // The helper is a thin wrapper — it must NOT clone the document. A + // copy would silently break callers that rely on identity (e.g. for + // post-publish hooks that mutate the original). + const doc = { '@id': 'urn:test:3' }; + + const privateEnv = wrapJsonLdContent(doc, { private: true }) as { private: object }; + const publicEnv = wrapJsonLdContent(doc, { private: false }) as { public: object }; + + expect(privateEnv.private).toBe(doc); + expect(publicEnv.public).toBe(doc); + }); + + it('accepts an array of JSON-LD documents (graph form)', () => { + // JsonLdDocument is `Record | Record[]` + // so an array of objects must round-trip through the envelope unchanged. + const docs = [{ '@id': 'urn:a' }, { '@id': 'urn:b' }]; + + const envelope = wrapJsonLdContent(docs, { private: true }) as { private: unknown }; + + expect(envelope.private).toBe(docs); + }); +}); From f90d68a53895ff501bde6f8cb74f89349cc89abb Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Mon, 4 May 2026 21:14:16 +0200 Subject: [PATCH 08/12] refactor(daemon): add validateOptionalBoolean http-utils helper Slice 02's useLocalCg flag and slice 07's subscription privacy flag both need the same strict-boolean-or-undefined validation that kafka.ts shipped inline in slice 03. Promotes the validator into http-utils so future routes adopt one helper instead of copy-pasting the predicate. The "safe failure mode" comment on the !== false predicate is preserved because that semantic isn't captured by the validator's docstring. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/daemon/http-utils.ts | 20 ++++++++++++++++++++ packages/cli/src/daemon/routes/kafka.ts | 9 ++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/daemon/http-utils.ts b/packages/cli/src/daemon/http-utils.ts index 73847a336..145122d17 100644 --- a/packages/cli/src/daemon/http-utils.ts +++ b/packages/cli/src/daemon/http-utils.ts @@ -270,6 +270,26 @@ export function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } +/** + * Validate an optional boolean field. Returns true if the field is + * absent (undefined) or a real boolean. Returns false (and writes a + * 400 response) if the field is present but not a boolean. + * + * Use this at boundaries where the omitted-defaults-to-something + * semantic must be preserved AND truthy/falsy coercion would create + * an unsafe ambiguity (e.g., privacy flags). + */ +export function validateOptionalBoolean( + value: unknown, + fieldName: string, + res: ServerResponse, +): boolean { + if (value === undefined) return true; + if (typeof value === 'boolean') return true; + jsonResponse(res, 400, { error: `"${fieldName}" must be a boolean` }); + return false; +} + export function validateRequiredContextGraphId( contextGraphId: unknown, res: ServerResponse, diff --git a/packages/cli/src/daemon/routes/kafka.ts b/packages/cli/src/daemon/routes/kafka.ts index 8e40dfe4f..24ff95ec5 100644 --- a/packages/cli/src/daemon/routes/kafka.ts +++ b/packages/cli/src/daemon/routes/kafka.ts @@ -2,6 +2,7 @@ import { isNonEmptyString, jsonResponse, readBody, + validateOptionalBoolean, validateRequiredContextGraphId, } from '../http-utils.js'; import { wrapJsonLdContent } from '../json-ld-envelope.js'; @@ -51,13 +52,7 @@ export async function handleKafkaRoutes(ctx: RequestContext): Promise { return jsonResponse(res, 400, { error: '"messageFormat" must be a non-empty string' }); } - // Privacy boundary: enforce a strict boolean to keep the contract - // unambiguous. Mirrors the strict typing applied above to - // broker/topic/messageFormat — string "false", numbers, etc. are - // rejected rather than silently coerced. - if (privateField !== undefined && typeof privateField !== 'boolean') { - return jsonResponse(res, 400, { error: '"private" must be a boolean' }); - } + if (!validateOptionalBoolean(privateField, 'private', res)) return; // Default to private: true. Callers opt into a public KA by sending // `private: false` in the request body. The `!== false` predicate is From b0a0dde536b797e7530a0f217f7637194dff2a7b Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Mon, 4 May 2026 21:16:31 +0200 Subject: [PATCH 09/12] refactor(test): extract reusable route test helpers Slices 02 (local-vs-shared CG) and 07 (subscription) both touch the kafka route and will need exactly the same in-process mocks for IncomingMessage / ServerResponse / RequestContext that slice 03 wrote. Extracting now establishes the shared module before those tickets land. makeFakeRequest gains optional method/url overrides; makeRequestContext generalizes the agent-publish capture so callers can either rely on the default capturing mock or supply an onPublish hook to drive custom behavior. The 6 existing kafka route tests are unchanged in semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/test/daemon-routes-kafka.test.ts | 128 ++++-------------- packages/cli/test/helpers/route-test-utils.ts | 125 +++++++++++++++++ 2 files changed, 151 insertions(+), 102 deletions(-) create mode 100644 packages/cli/test/helpers/route-test-utils.ts diff --git a/packages/cli/test/daemon-routes-kafka.test.ts b/packages/cli/test/daemon-routes-kafka.test.ts index 68dd15679..28bab1893 100644 --- a/packages/cli/test/daemon-routes-kafka.test.ts +++ b/packages/cli/test/daemon-routes-kafka.test.ts @@ -6,101 +6,19 @@ * before passing it to agent.publish(). The kafka package itself stays agnostic. * * These tests invoke handleKafkaRoutes directly with a minimal RequestContext - * mock — no real daemon, no network, no chain. + * mock — no real daemon, no network, no chain. The fakes live in + * test/helpers/route-test-utils.ts and are reused by future route tests. */ import { describe, it, expect } from 'vitest'; -import { EventEmitter } from 'node:events'; -import type { IncomingMessage, ServerResponse } from 'node:http'; import { handleKafkaRoutes } from '../src/daemon/routes/kafka.js'; -import type { RequestContext } from '../src/daemon/routes/context.js'; +import { + makeFakeRequest, + makeFakeResponse, + makeRequestContext, +} from './helpers/route-test-utils.js'; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** - * Make a minimal fake IncomingMessage for POST /api/kafka/endpoint with the - * given JSON body. - * - * readBody() uses req.on('data' | 'end' | 'error') so we simulate an - * EventEmitter that emits those events synchronously after the first tick. - */ -function makeFakeRequest(body: object): IncomingMessage { - const bodyStr = JSON.stringify(body); - const emitter = new EventEmitter() as unknown as IncomingMessage; - emitter.method = 'POST'; - emitter.url = '/api/kafka/endpoint'; - - // Emit data/end on the next tick so listeners are attached first - setImmediate(() => { - emitter.emit('data', Buffer.from(bodyStr)); - emitter.emit('end'); - }); - - return emitter; -} - -/** - * Make a fake ServerResponse that captures the status and JSON body. - * - * jsonResponse() calls writeHead(status, headers) then end(body), so we - * must accept the (status, headers?) overload of writeHead. - */ -function makeFakeResponse(): { res: ServerResponse; getResult: () => { status: number; body: unknown } } { - let capturedStatus = 0; - let capturedBody: unknown = null; - - const res = new EventEmitter() as unknown as ServerResponse; - (res as any).writeHead = (status: number, _headers?: unknown) => { - capturedStatus = status; - return res; - }; - (res as any).end = (data?: string) => { - try { - capturedBody = data ? JSON.parse(data) : null; - } catch { - capturedBody = data; - } - return res; - }; - - return { - res, - getResult: () => ({ status: capturedStatus, body: capturedBody }), - }; -} - -/** - * Build a minimal RequestContext with a mock agent.publish that captures calls. - */ -function makeContext(req: IncomingMessage, res: ServerResponse): { - ctx: RequestContext; - publishCalls: Array<{ cgId: string; envelope: unknown }>; -} { - const publishCalls: Array<{ cgId: string; envelope: unknown }> = []; - - const mockAgent = { - async publish(cgId: string, envelope: unknown) { - publishCalls.push({ cgId, envelope }); - return { ual: 'did:dkg:test/1', kcId: '1', status: 'confirmed' as const }; - }, - }; - - const ctx: Partial = { - req, - res, - agent: mockAgent as unknown as RequestContext['agent'], - requestAgentAddress: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', - path: '/api/kafka/endpoint', - }; - - return { ctx: ctx as RequestContext, publishCalls }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- +const KAFKA_ENDPOINT_URL = '/api/kafka/endpoint'; const VALID_BASE_BODY = { contextGraphId: 'devnet-test', @@ -111,9 +29,9 @@ const VALID_BASE_BODY = { describe('Kafka route adapter — privacy envelope', () => { it('wraps with { private: KA } when private: true is in request body', async () => { - const req = makeFakeRequest({ ...VALID_BASE_BODY, private: true }); + const req = makeFakeRequest({ ...VALID_BASE_BODY, private: true }, { url: KAFKA_ENDPOINT_URL }); const { res, getResult } = makeFakeResponse(); - const { ctx, publishCalls } = makeContext(req, res); + const { ctx, publishCalls } = makeRequestContext(req, res); await handleKafkaRoutes(ctx); @@ -130,9 +48,9 @@ describe('Kafka route adapter — privacy envelope', () => { }); it('wraps with { public: KA } when private: false is in request body', async () => { - const req = makeFakeRequest({ ...VALID_BASE_BODY, private: false }); + const req = makeFakeRequest({ ...VALID_BASE_BODY, private: false }, { url: KAFKA_ENDPOINT_URL }); const { res, getResult } = makeFakeResponse(); - const { ctx, publishCalls } = makeContext(req, res); + const { ctx, publishCalls } = makeRequestContext(req, res); await handleKafkaRoutes(ctx); @@ -150,9 +68,9 @@ describe('Kafka route adapter — privacy envelope', () => { it('defaults to { private: KA } when private field is omitted from request body', async () => { // No `private` field — route defaults to private: true - const req = makeFakeRequest(VALID_BASE_BODY); + const req = makeFakeRequest(VALID_BASE_BODY, { url: KAFKA_ENDPOINT_URL }); const { res, getResult } = makeFakeResponse(); - const { ctx, publishCalls } = makeContext(req, res); + const { ctx, publishCalls } = makeRequestContext(req, res); await handleKafkaRoutes(ctx); @@ -169,9 +87,12 @@ describe('Kafka route adapter — privacy envelope', () => { }); it('returns 400 when contextGraphId is missing', async () => { - const req = makeFakeRequest({ broker: 'x:9092', topic: 't', messageFormat: 'application/json' }); + const req = makeFakeRequest( + { broker: 'x:9092', topic: 't', messageFormat: 'application/json' }, + { url: KAFKA_ENDPOINT_URL }, + ); const { res, getResult } = makeFakeResponse(); - const { ctx } = makeContext(req, res); + const { ctx } = makeRequestContext(req, res); await handleKafkaRoutes(ctx); @@ -179,9 +100,12 @@ describe('Kafka route adapter — privacy envelope', () => { }); it('returns 400 when broker is missing', async () => { - const req = makeFakeRequest({ contextGraphId: 'devnet-test', topic: 't', messageFormat: 'application/json' }); + const req = makeFakeRequest( + { contextGraphId: 'devnet-test', topic: 't', messageFormat: 'application/json' }, + { url: KAFKA_ENDPOINT_URL }, + ); const { res, getResult } = makeFakeResponse(); - const { ctx } = makeContext(req, res); + const { ctx } = makeRequestContext(req, res); await handleKafkaRoutes(ctx); @@ -192,9 +116,9 @@ describe('Kafka route adapter — privacy envelope', () => { // The route enforces a strict boolean for `private` to keep the privacy // contract unambiguous. Truthy/falsy coercion would create an unsafe // ambiguity at a privacy boundary. - const req = makeFakeRequest({ ...VALID_BASE_BODY, private: 'false' }); + const req = makeFakeRequest({ ...VALID_BASE_BODY, private: 'false' }, { url: KAFKA_ENDPOINT_URL }); const { res, getResult } = makeFakeResponse(); - const { ctx, publishCalls } = makeContext(req, res); + const { ctx, publishCalls } = makeRequestContext(req, res); await handleKafkaRoutes(ctx); diff --git a/packages/cli/test/helpers/route-test-utils.ts b/packages/cli/test/helpers/route-test-utils.ts new file mode 100644 index 000000000..0f0903730 --- /dev/null +++ b/packages/cli/test/helpers/route-test-utils.ts @@ -0,0 +1,125 @@ +/** + * Shared in-process mocks for unit-testing daemon route handlers. + * + * Contract: each helper produces the minimum surface a route handler needs + * (a fake IncomingMessage, a fake ServerResponse, a partial RequestContext) + * with NO real daemon, NO network, NO chain, and NO Hardhat. Tests invoke + * the route handler directly and assert on captured side effects. + * + * Used by: + * - daemon-routes-kafka.test.ts (slice 03 — privacy envelope) + * - reserved for slice 02 (local-vs-shared CG) and slice 07 (subscription) + * once those tickets land, both of which exercise routes/kafka.ts and + * need identical mocks. + */ + +import { EventEmitter } from 'node:events'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { RequestContext } from '../../src/daemon/routes/context.js'; + +const DEFAULT_AGENT_ADDRESS = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + +/** + * Make a minimal fake IncomingMessage. Body (if provided) is JSON-encoded + * and emitted via 'data'/'end' events on the next tick — matching what + * readBody() in `http-utils.ts` consumes. + * + * Defaults to POST so the common case stays terse; pass `method`/`url` + * overrides for routes that accept other verbs/paths. + */ +export function makeFakeRequest( + body: object | null, + overrides: { method?: string; url?: string } = {}, +): IncomingMessage { + const emitter = new EventEmitter() as unknown as IncomingMessage; + emitter.method = overrides.method ?? 'POST'; + emitter.url = overrides.url ?? '/'; + + // Emit data/end on the next tick so listeners are attached first + setImmediate(() => { + if (body !== null) { + emitter.emit('data', Buffer.from(JSON.stringify(body))); + } + emitter.emit('end'); + }); + + return emitter; +} + +export interface FakeResponse { + res: ServerResponse; + getResult: () => { status: number; body: unknown }; +} + +/** + * Make a fake ServerResponse that captures the status + JSON body written + * via writeHead/end. jsonResponse() in http-utils.ts calls + * writeHead(status, headers) then end(body), so both overloads must work. + */ +export function makeFakeResponse(): FakeResponse { + let capturedStatus = 0; + let capturedBody: unknown = null; + + const res = new EventEmitter() as unknown as ServerResponse; + (res as any).writeHead = (status: number, _headers?: unknown) => { + capturedStatus = status; + return res; + }; + (res as any).end = (data?: string) => { + try { + capturedBody = data ? JSON.parse(data) : null; + } catch { + capturedBody = data; + } + return res; + }; + + return { + res, + getResult: () => ({ status: capturedStatus, body: capturedBody }), + }; +} + +export interface PublishCall { + cgId: string; + envelope: unknown; +} + +export type PublishHook = (cgId: string, envelope: unknown) => Promise; + +/** + * Build a minimal RequestContext with a mock agent.publish that captures + * every call. All fields are overridable so route-specific tests can + * inject a different `path`, `requestAgentAddress`, etc. + * + * The returned `publishCalls` array is shared mutable state — assert on it + * after invoking the route handler. + */ +export function makeRequestContext( + req: IncomingMessage, + res: ServerResponse, + overrides: Partial & { onPublish?: PublishHook } = {}, +): { ctx: RequestContext; publishCalls: PublishCall[] } { + const publishCalls: PublishCall[] = []; + // Strip the test-only `onPublish` before merging the rest into the ctx. + const { onPublish, ...ctxOverrides } = overrides; + + const mockAgent = { + async publish(cgId: string, envelope: unknown) { + publishCalls.push({ cgId, envelope }); + if (onPublish) return onPublish(cgId, envelope); + return { ual: 'did:dkg:test/1', kcId: '1', status: 'confirmed' as const }; + }, + }; + + const ctx: Partial = { + req, + res, + agent: mockAgent as unknown as RequestContext['agent'], + requestAgentAddress: DEFAULT_AGENT_ADDRESS, + path: req.url ?? '/', + ...ctxOverrides, + }; + + return { ctx: ctx as RequestContext, publishCalls }; +} From c1a527346ff9e6b827ecb633c7a010ef6e8b9734 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Mon, 4 May 2026 21:23:17 +0200 Subject: [PATCH 10/12] docs(test): clarify onPublish/publishCalls semantics in route-test-utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The makeRequestContext docstring did not capture the load-bearing detail that publish capture is unconditional — supplying onPublish drives the return value to the route handler but does NOT silence publishCalls. Future test authors needed this contract spelled out. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/test/helpers/route-test-utils.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/test/helpers/route-test-utils.ts b/packages/cli/test/helpers/route-test-utils.ts index 0f0903730..01474c48e 100644 --- a/packages/cli/test/helpers/route-test-utils.ts +++ b/packages/cli/test/helpers/route-test-utils.ts @@ -94,6 +94,12 @@ export type PublishHook = (cgId: string, envelope: unknown) => Promise; * * The returned `publishCalls` array is shared mutable state — assert on it * after invoking the route handler. + * + * When `overrides.onPublish` is supplied, calls are still captured into + * `publishCalls` (capture is unconditional); `onPublish` only controls what + * `agent.publish()` returns to the route handler. This lets tests both + * assert on the call shape AND drive custom return values (e.g., to + * simulate a publish failure) without losing visibility into what was sent. */ export function makeRequestContext( req: IncomingMessage, From 655cb689b9a4451ad42bd426e9b9f843ac0025b9 Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Mon, 4 May 2026 22:55:22 +0200 Subject: [PATCH 11/12] refactor(daemon): tighten docstrings and predicate comment Trim multi-line JSDoc that re-stated what function names and signatures already say. Keep only load-bearing rationale: the "do not write a response" half-line on isNonEmptyString (distinguishes from validate* helpers), the "do NOT tighten to === true" warning on the privacy predicate, the next-tick emit detail on makeFakeRequest, and the publishCalls/onPublish contract on makeRequestContext. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/daemon/http-utils.ts | 20 ++------ packages/cli/src/daemon/json-ld-envelope.ts | 10 +--- packages/cli/src/daemon/routes/kafka.ts | 9 +--- packages/cli/test/helpers/route-test-utils.ts | 47 +++---------------- 4 files changed, 12 insertions(+), 74 deletions(-) diff --git a/packages/cli/src/daemon/http-utils.ts b/packages/cli/src/daemon/http-utils.ts index 145122d17..dd59e115e 100644 --- a/packages/cli/src/daemon/http-utils.ts +++ b/packages/cli/src/daemon/http-utils.ts @@ -260,32 +260,18 @@ export function validateOptionalSubGraphName( return true; } -/** - * Generic guard: narrows `unknown` to a non-empty trimmed string. Useful in - * route handlers that need to validate string fields without coupling each - * one to a domain-specific validator. Does NOT write a response — caller - * decides the error semantics. - */ +/** Type guard: non-empty trimmed string. Does not write a response — caller handles the 400. */ export function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } -/** - * Validate an optional boolean field. Returns true if the field is - * absent (undefined) or a real boolean. Returns false (and writes a - * 400 response) if the field is present but not a boolean. - * - * Use this at boundaries where the omitted-defaults-to-something - * semantic must be preserved AND truthy/falsy coercion would create - * an unsafe ambiguity (e.g., privacy flags). - */ +/** Optional-boolean validator. Returns false (and writes a 400) if the field is present but not a boolean. */ export function validateOptionalBoolean( value: unknown, fieldName: string, res: ServerResponse, ): boolean { - if (value === undefined) return true; - if (typeof value === 'boolean') return true; + if (value === undefined || typeof value === 'boolean') return true; jsonResponse(res, 400, { error: `"${fieldName}" must be a boolean` }); return false; } diff --git a/packages/cli/src/daemon/json-ld-envelope.ts b/packages/cli/src/daemon/json-ld-envelope.ts index c41518442..a57395a70 100644 --- a/packages/cli/src/daemon/json-ld-envelope.ts +++ b/packages/cli/src/daemon/json-ld-envelope.ts @@ -1,14 +1,6 @@ import type { JsonLdContent, JsonLdDocument } from '@origintrail-official/dkg-agent'; -/** - * Wrap a JSON-LD document in the `{ public }` or `{ private }` envelope - * shape that DKGAgent.publish() expects. - * - * The envelope choice is the privacy boundary: `{ private: ... }` is - * encrypted to context-graph participants per V10's private-KA flow; - * `{ public: ... }` is cleartext. See packages/agent/src/dkg-agent.ts - * publish() overloads for the contract. - */ +/** Wrap a JSON-LD document in the `{ public }` or `{ private }` envelope `DKGAgent.publish` expects. */ export function wrapJsonLdContent( content: JsonLdDocument, options: { private: boolean }, diff --git a/packages/cli/src/daemon/routes/kafka.ts b/packages/cli/src/daemon/routes/kafka.ts index 24ff95ec5..be91edd71 100644 --- a/packages/cli/src/daemon/routes/kafka.ts +++ b/packages/cli/src/daemon/routes/kafka.ts @@ -54,13 +54,8 @@ export async function handleKafkaRoutes(ctx: RequestContext): Promise { if (!validateOptionalBoolean(privateField, 'private', res)) return; - // Default to private: true. Callers opt into a public KA by sending - // `private: false` in the request body. The `!== false` predicate is - // intentional: only the literal boolean `false` flips to public; any - // other accepted value (omitted/undefined, after the type-check above) - // resolves to private. This is the safe failure mode for a - // privacy-sensitive boundary — a future "tightening" to `=== true` - // would silently break the omitted-defaults-to-private semantic. + // `!== false` is intentional: only literal `false` opts in to public; omitted/undefined defaults to private. + // Do NOT tighten to `=== true` — that would silently break the omitted-defaults-to-private contract. const isPrivate = privateField !== false; const publisher: KafkaEndpointPublisher = { diff --git a/packages/cli/test/helpers/route-test-utils.ts b/packages/cli/test/helpers/route-test-utils.ts index 01474c48e..ce8445561 100644 --- a/packages/cli/test/helpers/route-test-utils.ts +++ b/packages/cli/test/helpers/route-test-utils.ts @@ -1,17 +1,4 @@ -/** - * Shared in-process mocks for unit-testing daemon route handlers. - * - * Contract: each helper produces the minimum surface a route handler needs - * (a fake IncomingMessage, a fake ServerResponse, a partial RequestContext) - * with NO real daemon, NO network, NO chain, and NO Hardhat. Tests invoke - * the route handler directly and assert on captured side effects. - * - * Used by: - * - daemon-routes-kafka.test.ts (slice 03 — privacy envelope) - * - reserved for slice 02 (local-vs-shared CG) and slice 07 (subscription) - * once those tickets land, both of which exercise routes/kafka.ts and - * need identical mocks. - */ +/** Shared in-process mocks for unit-testing daemon route handlers. No real daemon, network, chain, or Hardhat. */ import { EventEmitter } from 'node:events'; import type { IncomingMessage, ServerResponse } from 'node:http'; @@ -19,14 +6,7 @@ import type { RequestContext } from '../../src/daemon/routes/context.js'; const DEFAULT_AGENT_ADDRESS = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; -/** - * Make a minimal fake IncomingMessage. Body (if provided) is JSON-encoded - * and emitted via 'data'/'end' events on the next tick — matching what - * readBody() in `http-utils.ts` consumes. - * - * Defaults to POST so the common case stays terse; pass `method`/`url` - * overrides for routes that accept other verbs/paths. - */ +/** Fake IncomingMessage; body is JSON-encoded and emitted via data/end events on the next tick. */ export function makeFakeRequest( body: object | null, overrides: { method?: string; url?: string } = {}, @@ -35,7 +15,7 @@ export function makeFakeRequest( emitter.method = overrides.method ?? 'POST'; emitter.url = overrides.url ?? '/'; - // Emit data/end on the next tick so listeners are attached first + // Defer emit so listeners attach first. setImmediate(() => { if (body !== null) { emitter.emit('data', Buffer.from(JSON.stringify(body))); @@ -51,11 +31,7 @@ export interface FakeResponse { getResult: () => { status: number; body: unknown }; } -/** - * Make a fake ServerResponse that captures the status + JSON body written - * via writeHead/end. jsonResponse() in http-utils.ts calls - * writeHead(status, headers) then end(body), so both overloads must work. - */ +/** Fake ServerResponse capturing status + JSON body. Supports the writeHead(status, headers?) overload jsonResponse uses. */ export function makeFakeResponse(): FakeResponse { let capturedStatus = 0; let capturedBody: unknown = null; @@ -88,18 +64,8 @@ export interface PublishCall { export type PublishHook = (cgId: string, envelope: unknown) => Promise; /** - * Build a minimal RequestContext with a mock agent.publish that captures - * every call. All fields are overridable so route-specific tests can - * inject a different `path`, `requestAgentAddress`, etc. - * - * The returned `publishCalls` array is shared mutable state — assert on it - * after invoking the route handler. - * - * When `overrides.onPublish` is supplied, calls are still captured into - * `publishCalls` (capture is unconditional); `onPublish` only controls what - * `agent.publish()` returns to the route handler. This lets tests both - * assert on the call shape AND drive custom return values (e.g., to - * simulate a publish failure) without losing visibility into what was sent. + * Build a minimal RequestContext; the mock agent.publish appends every call to `publishCalls`. + * `overrides.onPublish` overrides the publish return value; `publishCalls` is still populated. */ export function makeRequestContext( req: IncomingMessage, @@ -107,7 +73,6 @@ export function makeRequestContext( overrides: Partial & { onPublish?: PublishHook } = {}, ): { ctx: RequestContext; publishCalls: PublishCall[] } { const publishCalls: PublishCall[] = []; - // Strip the test-only `onPublish` before merging the rest into the ctx. const { onPublish, ...ctxOverrides } = overrides; const mockAgent = { From 43d58cacca86c365b3aa9e8246b9aca707e715ba Mon Sep 17 00:00:00 2001 From: Zvonimir Date: Tue, 5 May 2026 00:14:17 +0200 Subject: [PATCH 12/12] docs(cli): drop unverified "encrypted to CG participants" claim from --public help The walking-skeleton e2e test explicitly notes that the participant-encryption behavior of V10's `{private: ...}` envelope was out of scope to verify on a single-node devnet. The simpler "default: private" tells the user the only thing they need to know to use the flag correctly; ADR-0004 carries the semantic detail. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5020ac1d5..4d59c6166 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1732,7 +1732,7 @@ kafkaEndpointCmd .requiredOption('--broker ', 'Kafka broker host:port') .requiredOption('--topic ', 'Kafka topic name') .option('--format ', 'Kafka message format MIME type', 'application/json') - .option('--public', 'Publish the endpoint as a public KA (default: private, encrypted to CG participants)') + .option('--public', 'Publish the endpoint as a public KA (default: private)') .action(async (opts: ActionOpts) => { try { const client = await ApiClient.connect();