@aroha-sdk/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Aroha Protocol — Layer 0 Transport Client
3
+ *
4
+ * Sends Aroha messages to remote agents over HTTPS.
5
+ * Handles streaming responses via WebSocket.
6
+ *
7
+ * This is protocol infrastructure. It does not know what the messages
8
+ * mean or what to do with responses — that is the orchestrator's job.
9
+ */
10
+
11
+ import { WebSocket } from "ws";
12
+ import { type ArohaEnvelope } from "../messages/envelope.js";
13
+
14
+ // ─── Connection Pool ──────────────────────────────────────────────────────────
15
+
16
+ export interface ConnectionPoolConfig {
17
+ /**
18
+ * Maximum number of concurrent connections per origin (scheme+host+port).
19
+ * Default: 10
20
+ */
21
+ connections?: number;
22
+ /**
23
+ * How long an idle connection is kept alive in ms.
24
+ * Default: 60_000 (60 seconds)
25
+ */
26
+ keepAliveTimeoutMs?: number;
27
+ }
28
+
29
+ /**
30
+ * AgentConnectionPool — maintains persistent HTTP connections per origin.
31
+ *
32
+ * Uses undici (built into Node.js 22+) to pool connections, eliminating
33
+ * the TCP+TLS handshake overhead on every ArohaClient.send() call.
34
+ *
35
+ * For an orchestrator talking to 20 agents at 100 msg/s, this removes
36
+ * ~2,000 TLS handshakes per second (~400ms each = 800s of saved latency/s).
37
+ *
38
+ * Usage:
39
+ * const pool = new AgentConnectionPool();
40
+ * const client = new ArohaClient({ pool });
41
+ */
42
+ export class AgentConnectionPool {
43
+ private readonly connections: number;
44
+ private readonly keepAliveTimeoutMs: number;
45
+ // Map from origin → undici Pool instance (lazy-created)
46
+ private readonly pools = new Map<string, import("undici").Pool>();
47
+
48
+ constructor(config: ConnectionPoolConfig = {}) {
49
+ this.connections = config.connections ?? 10;
50
+ this.keepAliveTimeoutMs = config.keepAliveTimeoutMs ?? 60_000;
51
+ }
52
+
53
+ async fetch(url: string, init: RequestInit): Promise<Response> {
54
+ const origin = new URL(url).origin;
55
+ let pool = this.pools.get(origin);
56
+
57
+ if (!pool) {
58
+ const { Pool } = await import("undici");
59
+ pool = new Pool(origin, {
60
+ connections: this.connections,
61
+ keepAliveTimeout: this.keepAliveTimeoutMs / 1000,
62
+ keepAliveMaxTimeout: this.keepAliveTimeoutMs / 1000,
63
+ });
64
+ this.pools.set(origin, pool);
65
+ }
66
+
67
+ const { fetch: undiciFetch } = await import("undici");
68
+ return undiciFetch(url, { ...init, dispatcher: pool } as Parameters<typeof undiciFetch>[1]) as unknown as Response;
69
+ }
70
+
71
+ async destroy(): Promise<void> {
72
+ await Promise.all([...this.pools.values()].map((p) => p.destroy()));
73
+ this.pools.clear();
74
+ }
75
+
76
+ get poolCount(): number {
77
+ return this.pools.size;
78
+ }
79
+ }
80
+
81
+ // ─── Types ────────────────────────────────────────────────────────────────────
82
+
83
+ export interface SendOptions {
84
+ timeoutMs?: number;
85
+ }
86
+
87
+ export interface ArohaClientOptions {
88
+ /** Optional connection pool for persistent keep-alive connections. */
89
+ pool?: AgentConnectionPool;
90
+ }
91
+
92
+ // ─── Client ───────────────────────────────────────────────────────────────────
93
+
94
+ export class ArohaClient {
95
+ private readonly pool?: AgentConnectionPool;
96
+
97
+ constructor(opts: ArohaClientOptions = {}) {
98
+ this.pool = opts.pool;
99
+ }
100
+
101
+ /**
102
+ * Send an Aroha message and await a synchronous response.
103
+ * Returns the response ArohaEnvelope, or null when the provider responds
104
+ * with 202 Accepted (async / streaming — use stream() for WebSocket events).
105
+ */
106
+ async send(
107
+ endpoint: string,
108
+ envelope: ArohaEnvelope,
109
+ opts: SendOptions = {}
110
+ ): Promise<ArohaEnvelope | null> {
111
+ const { timeoutMs = 30_000 } = opts;
112
+ const controller = new AbortController();
113
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
114
+
115
+ const fetchFn = this.pool
116
+ ? (url: string, init: RequestInit) => this.pool!.fetch(url, init)
117
+ : fetch;
118
+
119
+ let response: Response;
120
+ try {
121
+ response = await fetchFn(endpoint, {
122
+ method: "POST",
123
+ headers: { "Content-Type": "application/json" },
124
+ body: JSON.stringify(envelope),
125
+ signal: controller.signal,
126
+ });
127
+ } finally {
128
+ clearTimeout(timer);
129
+ }
130
+
131
+ if (response.status === 202) {
132
+ return null;
133
+ }
134
+
135
+ if (!response.ok) {
136
+ const error = await response.text();
137
+ throw new ArohaTransportError(response.status, error);
138
+ }
139
+
140
+ return response.json() as Promise<ArohaEnvelope>;
141
+ }
142
+
143
+ /**
144
+ * Open a WebSocket stream to receive ArohaStream events for a given saga.
145
+ *
146
+ * @param wsEndpoint WebSocket URL (wss://<host>/aroha/stream)
147
+ * @param correlationId The saga correlationId to subscribe to
148
+ * @param streamToken One-time token from the 202 response (recommended)
149
+ */
150
+ stream(
151
+ wsEndpoint: string,
152
+ correlationId: string,
153
+ streamToken?: string
154
+ ): AsyncIterable<ArohaEnvelope> {
155
+ const u = new URL(wsEndpoint);
156
+ u.searchParams.set("cid", correlationId);
157
+ if (streamToken) u.searchParams.set("tok", streamToken);
158
+ const ws = new WebSocket(u.toString());
159
+
160
+ return {
161
+ [Symbol.asyncIterator]() {
162
+ const queue: ArohaEnvelope[] = [];
163
+ let resolve: ((value: IteratorResult<ArohaEnvelope>) => void) | null = null;
164
+ let done = false;
165
+ let error: Error | null = null;
166
+
167
+ ws.on("message", (data) => {
168
+ const envelope = JSON.parse(data.toString()) as ArohaEnvelope;
169
+ if (resolve) {
170
+ const r = resolve;
171
+ resolve = null;
172
+ r({ value: envelope, done: false });
173
+ } else {
174
+ queue.push(envelope);
175
+ }
176
+ });
177
+
178
+ ws.on("close", () => {
179
+ done = true;
180
+ if (resolve) {
181
+ const r = resolve;
182
+ resolve = null;
183
+ r({ value: undefined as unknown as ArohaEnvelope, done: true });
184
+ }
185
+ });
186
+
187
+ ws.on("error", (err) => {
188
+ error = err;
189
+ if (resolve) {
190
+ const r = resolve;
191
+ resolve = null;
192
+ r({ value: undefined as unknown as ArohaEnvelope, done: true });
193
+ }
194
+ });
195
+
196
+ return {
197
+ next(): Promise<IteratorResult<ArohaEnvelope>> {
198
+ if (error) return Promise.reject(error);
199
+ if (queue.length > 0) {
200
+ return Promise.resolve({ value: queue.shift()!, done: false });
201
+ }
202
+ if (done) {
203
+ return Promise.resolve({ value: undefined as unknown as ArohaEnvelope, done: true });
204
+ }
205
+ return new Promise((r) => { resolve = r; });
206
+ },
207
+ return(): Promise<IteratorResult<ArohaEnvelope>> {
208
+ ws.close();
209
+ return Promise.resolve({ value: undefined as unknown as ArohaEnvelope, done: true });
210
+ },
211
+ };
212
+ },
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Fetch the DID Document from an agent's well-known endpoint.
218
+ */
219
+ async fetchDIDDocument(agentBaseUrl: string): Promise<Record<string, unknown>> {
220
+ const response = await fetch(`${agentBaseUrl}/.well-known/aroha-agent.json`);
221
+ if (!response.ok) throw new Error(`Failed to fetch DID Document from ${agentBaseUrl}`);
222
+ return response.json() as Promise<Record<string, unknown>>;
223
+ }
224
+ }
225
+
226
+ // ─── Errors ───────────────────────────────────────────────────────────────────
227
+
228
+ export class ArohaTransportError extends Error {
229
+ constructor(
230
+ public readonly statusCode: number,
231
+ public readonly body: string
232
+ ) {
233
+ super(`Aroha transport error ${statusCode}: ${body}`);
234
+ this.name = "ArohaTransportError";
235
+ }
236
+ }
@@ -0,0 +1,30 @@
1
+ import { type IncomingMessage } from "http";
2
+
3
+ export function readBody(req: IncomingMessage, maxBytes = 1_048_576): Promise<string> {
4
+ return new Promise((resolve, reject) => {
5
+ const chunks: Buffer[] = [];
6
+ let totalBytes = 0;
7
+ let settled = false;
8
+
9
+ const done = (fn: () => void) => {
10
+ if (settled) return;
11
+ settled = true;
12
+ fn();
13
+ };
14
+
15
+ req.on("data", (chunk: Buffer) => {
16
+ totalBytes += chunk.length;
17
+ if (totalBytes > maxBytes) {
18
+ const err = new Error("PAYLOAD_TOO_LARGE");
19
+ (err as NodeJS.ErrnoException).code = "PAYLOAD_TOO_LARGE";
20
+ done(() => reject(err));
21
+ req.destroy();
22
+ return;
23
+ }
24
+ chunks.push(chunk);
25
+ });
26
+
27
+ req.on("end", () => done(() => resolve(Buffer.concat(chunks).toString("utf8"))));
28
+ req.on("error", (err) => done(() => reject(err)));
29
+ });
30
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./server.js";
2
+ export * from "./client.js";
3
+ export * from "./http-utils.js";
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Aroha Protocol — Layer 0 Transport Server (core)
3
+ *
4
+ * Responsibilities (protocol only):
5
+ * - Serve /.well-known/aroha-agent.json
6
+ * - Accept POST /aroha/v1, validate envelope (sig + nonce + expiry + recipient)
7
+ * - Run optional middleware chain before dispatch
8
+ * - Dispatch valid envelopes to the application handler
9
+ * - Handle WebSocket streams
10
+ * - Delegate unknown HTTP routes to optional httpRoutes handlers
11
+ *
12
+ * What this server deliberately does NOT do:
13
+ * - RBAC / credential verification → @aroha-sdk/credentials createRbacMiddleware()
14
+ * - Credential registry endpoints → @aroha-sdk/credentials credentialRegistryRoutes()
15
+ * - Settlement → @aroha-sdk/settlement
16
+ */
17
+
18
+ import { createServer, type IncomingMessage, type ServerResponse } from "http";
19
+ import { readBody } from "./http-utils.js";
20
+ import { WebSocketServer, WebSocket } from "ws";
21
+ import { randomBytes } from "@noble/hashes/utils";
22
+ import { type ArohaEnvelope } from "../messages/envelope.js";
23
+ import { validateEnvelope, type ValidationResult } from "../messages/envelope.js";
24
+ import { NonceRegistry, type NonceStore } from "../messages/nonce.js";
25
+ import { type DIDDocument } from "../identity/did.js";
26
+
27
+ const PROTOCOL_VERSION = "1.0";
28
+
29
+ // ─── Middleware types ──────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * A ArohaMiddleware runs after envelope validation and before dispatch.
33
+ * Call reject() to short-circuit with an HTTP error. Return without calling
34
+ * reject to pass the envelope to the next middleware or the handler.
35
+ */
36
+ export type ArohaMiddleware = (
37
+ envelope: ArohaEnvelope,
38
+ reject: (status: number, errorCode: string, reason: string) => void
39
+ ) => Promise<void>;
40
+
41
+ /**
42
+ * An additional HTTP route registered on ArohaServer.
43
+ * Use credentialRegistryRoutes() from @aroha-sdk/credentials to mount the
44
+ * credential registry without coupling aroha-core to auth logic.
45
+ */
46
+ export interface ArohaHttpRoute {
47
+ method: string;
48
+ test: (url: string) => boolean;
49
+ handle: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
50
+ }
51
+
52
+ // ─── Handler types ────────────────────────────────────────────────────────────
53
+
54
+ export type MessageHandler = (
55
+ envelope: ArohaEnvelope,
56
+ respond: (reply: ArohaEnvelope) => void,
57
+ stream: (event: ArohaEnvelope) => void
58
+ ) => Promise<void>;
59
+
60
+ import type { PublicKeyResolver } from "../identity/did-cache.js";
61
+ export type { PublicKeyResolver };
62
+
63
+ // ─── Options ──────────────────────────────────────────────────────────────────
64
+
65
+ export interface ArohaServerOptions {
66
+ agentDID: string;
67
+ didDocument: DIDDocument;
68
+ port: number;
69
+ onMessage: MessageHandler;
70
+ resolvePublicKey: PublicKeyResolver;
71
+ /**
72
+ * Optional did:aroha-web: document to serve at the well-known path.
73
+ * When set, the server serves GET /.well-known/aroha/agents/{path}/did.json
74
+ * in addition to the standard /.well-known/aroha-agent.json endpoint.
75
+ */
76
+ webDIDDocument?: import("../identity/web-did.js").WebDIDDocument;
77
+ /**
78
+ * Middleware chain — runs in order after envelope validation.
79
+ * Any middleware may call reject() to abort processing.
80
+ * Mount RBAC here via createRbacMiddleware() from @aroha-sdk/credentials.
81
+ */
82
+ middleware?: ArohaMiddleware[];
83
+ /**
84
+ * Additional HTTP route handlers mounted alongside /aroha/v1.
85
+ * Checked in order after built-in routes. First match wins.
86
+ * Mount credential registry here via credentialRegistryRoutes() from @aroha-sdk/credentials.
87
+ */
88
+ httpRoutes?: ArohaHttpRoute[];
89
+ /**
90
+ * Trusted mesh / VPC mode: skip Ed25519 signature verification for senders
91
+ * whose DID matches this predicate. Use within private networks protected by
92
+ * mTLS or a VPC where network-level trust replaces cryptographic identity.
93
+ * Configure via @aroha-sdk/trusted-mesh createTrustedMeshOptions().
94
+ */
95
+ bypassSignatureFor?: (senderDID: string) => boolean;
96
+ /**
97
+ * Development mode — skips ALL cryptographic validation (signatures, nonce
98
+ * replay, expiry) so you can spin up agents with zero ceremony on localhost.
99
+ *
100
+ * Set devMode: false (or omit) before deploying to production. The flip
101
+ * requires no other code changes — your capability handlers are identical.
102
+ *
103
+ * @aroha-sdk/micro sets this automatically when devMode: true is passed there.
104
+ */
105
+ devMode?: boolean;
106
+ /**
107
+ * Minimum client protocol version accepted.
108
+ * Requests with X-Aroha-Client-Version below this are rejected with 400.
109
+ * Default: "1.0".
110
+ */
111
+ minClientVersion?: string;
112
+ /**
113
+ * Custom nonce store for distributed deployments.
114
+ * Defaults to in-process MapNonceStore (breaks in multi-instance deployments).
115
+ * In production, inject a Redis-backed NonceStore to share nonce state
116
+ * across all instances and prevent cross-instance replay attacks.
117
+ */
118
+ nonceStore?: NonceStore;
119
+ /**
120
+ * Clock skew tolerance applied to envelope expiry checks, in milliseconds.
121
+ * Recommended value for cross-region deployments: 5000.
122
+ * Default: 0 (strict — rejects messages expired by even 1ms).
123
+ */
124
+ clockToleranceMs?: number;
125
+ /**
126
+ * Maximum allowed request body size in bytes.
127
+ * Requests exceeding this limit are rejected with HTTP 413.
128
+ * Default: 1_048_576 (1 MiB).
129
+ */
130
+ maxBodyBytes?: number;
131
+ }
132
+
133
+ // ─── Server ───────────────────────────────────────────────────────────────────
134
+
135
+ export class ArohaServer {
136
+ private readonly nonceRegistry: NonceRegistry;
137
+ private readonly wsClients = new Map<string, WebSocket>();
138
+ private readonly streamTokens = new Map<string, { cid: string; expiresMs: number }>();
139
+ private server: ReturnType<typeof createServer> | null = null;
140
+ private wss: WebSocketServer | null = null;
141
+
142
+ constructor(private readonly opts: ArohaServerOptions) {
143
+ this.nonceRegistry = new NonceRegistry(60_000, opts.nonceStore);
144
+ }
145
+
146
+ start(): Promise<void> {
147
+ if (this.opts.devMode) {
148
+ if (process.env.NODE_ENV !== "development") {
149
+ throw new Error(
150
+ `[Aroha] devMode can only be enabled when NODE_ENV=development. ` +
151
+ `Current NODE_ENV: "${process.env.NODE_ENV ?? "(unset)"}". ` +
152
+ `Remove devMode: true before deploying.`
153
+ );
154
+ }
155
+ console.warn(
156
+ "[Aroha] WARNING: devMode is ENABLED — ALL cryptographic validation is bypassed. " +
157
+ "Never use devMode in production."
158
+ );
159
+ }
160
+ return new Promise((resolve) => {
161
+ this.server = createServer((req, res) => this.handleHttp(req, res));
162
+ this.wss = new WebSocketServer({ server: this.server });
163
+ this.wss.on("connection", (ws, req) => this.handleWsConnection(ws, req));
164
+ this.server.listen(this.opts.port, () => {
165
+ console.log(`[Aroha] ${this.opts.agentDID} listening on port ${this.opts.port}`);
166
+ resolve();
167
+ });
168
+ });
169
+ }
170
+
171
+ stop(): Promise<void> {
172
+ this.nonceRegistry.destroy();
173
+ return new Promise((resolve, reject) => {
174
+ this.server?.close((err) => (err ? reject(err) : resolve()));
175
+ });
176
+ }
177
+
178
+ // ─── Version helpers ───────────────────────────────────────────────────────
179
+
180
+ private versionLessThan(a: string, b: string): boolean {
181
+ const [aMaj, aMin = 0] = a.split(".").map(Number);
182
+ const [bMaj, bMin = 0] = b.split(".").map(Number);
183
+ return aMaj < bMaj || (aMaj === bMaj && aMin < bMin);
184
+ }
185
+
186
+ private writeJson(res: ServerResponse, status: number, body: object): void {
187
+ res.writeHead(status, {
188
+ "Content-Type": "application/json",
189
+ "X-Aroha-Version": PROTOCOL_VERSION,
190
+ });
191
+ res.end(JSON.stringify(body));
192
+ }
193
+
194
+ // ─── HTTP ──────────────────────────────────────────────────────────────────
195
+
196
+ private async handleHttp(req: IncomingMessage, res: ServerResponse): Promise<void> {
197
+ const url = req.url ?? "/";
198
+
199
+ if (req.method === "GET" && url === "/.well-known/aroha-agent.json") {
200
+ res.writeHead(200, { "Content-Type": "application/json" });
201
+ res.end(JSON.stringify(this.opts.didDocument));
202
+ return;
203
+ }
204
+
205
+ // did:aroha-web: well-known document — served at the agent's specific path
206
+ if (req.method === "GET" && this.opts.webDIDDocument) {
207
+ const { wellKnownPath } = await import("../identity/web-did.js");
208
+ const expectedPath = wellKnownPath(this.opts.webDIDDocument.id);
209
+ if (url === expectedPath || url === expectedPath.replace(/\/did\.json$/, "")) {
210
+ res.writeHead(200, {
211
+ "Content-Type": "application/json",
212
+ "Access-Control-Allow-Origin": "*",
213
+ "Cache-Control": "no-cache",
214
+ });
215
+ res.end(JSON.stringify(this.opts.webDIDDocument, null, 2));
216
+ return;
217
+ }
218
+ }
219
+
220
+ if (req.method === "POST" && url === "/aroha/v1") {
221
+ await this.handleInbound(req, res);
222
+ return;
223
+ }
224
+
225
+ for (const route of this.opts.httpRoutes ?? []) {
226
+ if (route.method === req.method && route.test(url)) {
227
+ await route.handle(req, res);
228
+ return;
229
+ }
230
+ }
231
+
232
+ res.writeHead(404);
233
+ res.end();
234
+ }
235
+
236
+ private async handleInbound(req: IncomingMessage, res: ServerResponse): Promise<void> {
237
+ // ── Protocol version check ──────────────────────────────────────────────
238
+ const clientVersion = req.headers["x-aroha-client-version"] as string | undefined;
239
+ if (this.opts.minClientVersion) {
240
+ const effectiveVersion = clientVersion ?? "0.0";
241
+ if (this.versionLessThan(effectiveVersion, this.opts.minClientVersion)) {
242
+ this.writeJson(res, 400, {
243
+ error: `Protocol version ${effectiveVersion} below minimum ${this.opts.minClientVersion}`,
244
+ code: "VERSION_NOT_SUPPORTED",
245
+ serverVersion: PROTOCOL_VERSION,
246
+ });
247
+ return;
248
+ }
249
+ }
250
+
251
+ let body: string;
252
+ try {
253
+ body = await readBody(req, this.opts.maxBodyBytes ?? 1_048_576);
254
+ } catch (e) {
255
+ const code = (e as NodeJS.ErrnoException).code;
256
+ if (code === "PAYLOAD_TOO_LARGE") {
257
+ this.writeJson(res, 413, { error: "Payload too large" });
258
+ return;
259
+ }
260
+ this.writeJson(res, 400, { error: "Invalid body" });
261
+ return;
262
+ }
263
+
264
+ let envelope: ArohaEnvelope;
265
+ try { envelope = JSON.parse(body) as ArohaEnvelope; }
266
+ catch {
267
+ this.writeJson(res, 400, { error: "Invalid JSON" });
268
+ return;
269
+ }
270
+
271
+ // ── Dev mode: skip all crypto/auth — localhost hackathon mode ───────────
272
+ if (this.opts.devMode) {
273
+ let syncReply: ArohaEnvelope | null = null;
274
+ const respond = (reply: ArohaEnvelope) => { syncReply = reply; };
275
+ const stream = (event: ArohaEnvelope) => {
276
+ const ws = this.wsClients.get(envelope.correlationId);
277
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(event));
278
+ };
279
+ await this.opts.onMessage(envelope, respond, stream);
280
+ if (syncReply) {
281
+ this.writeJson(res, 200, syncReply);
282
+ } else {
283
+ const streamToken = Buffer.from(randomBytes(16)).toString("base64url");
284
+ this.streamTokens.set(streamToken, { cid: envelope.correlationId, expiresMs: Date.now() + 30_000 });
285
+ this.writeJson(res, 202, { streamToken });
286
+ }
287
+ return;
288
+ }
289
+
290
+ const senderPubKey = await this.opts.resolvePublicKey(envelope.from);
291
+ if (!senderPubKey) {
292
+ this.writeJson(res, 401, { error: "Unknown sender DID" });
293
+ return;
294
+ }
295
+
296
+ const skipSignature = this.opts.bypassSignatureFor?.(envelope.from) ?? false;
297
+ const result: ValidationResult = await validateEnvelope(
298
+ envelope,
299
+ senderPubKey,
300
+ this.opts.agentDID,
301
+ this.nonceRegistry,
302
+ {
303
+ ...(skipSignature ? { skipSignature: true } : {}),
304
+ ...(this.opts.clockToleranceMs !== undefined
305
+ ? { clockToleranceMs: this.opts.clockToleranceMs }
306
+ : {}),
307
+ }
308
+ );
309
+
310
+ if (!result.valid) {
311
+ this.writeJson(res, 401, { error: result.reason });
312
+ return;
313
+ }
314
+
315
+ // ── Middleware chain ────────────────────────────────────────────────────
316
+ for (const mw of this.opts.middleware ?? []) {
317
+ let wasRejected = false;
318
+ let rejectStatus = 0, rejectCode = "", rejectReason = "";
319
+ await mw(envelope, (status, code, reason) => {
320
+ wasRejected = true; rejectStatus = status; rejectCode = code; rejectReason = reason;
321
+ });
322
+ if (wasRejected) {
323
+ this.writeJson(res, rejectStatus, { error: rejectReason, code: rejectCode });
324
+ return;
325
+ }
326
+ }
327
+
328
+ // ── Dispatch ────────────────────────────────────────────────────────────
329
+ let syncReply: ArohaEnvelope | null = null;
330
+ const respond = (reply: ArohaEnvelope) => { syncReply = reply; };
331
+ const stream = (event: ArohaEnvelope) => {
332
+ const ws = this.wsClients.get(envelope.correlationId);
333
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(event));
334
+ };
335
+
336
+ await this.opts.onMessage(envelope, respond, stream);
337
+
338
+ if (syncReply) {
339
+ this.writeJson(res, 200, syncReply);
340
+ } else {
341
+ const streamToken = Buffer.from(randomBytes(16)).toString("base64url");
342
+ this.streamTokens.set(streamToken, { cid: envelope.correlationId, expiresMs: Date.now() + 30_000 });
343
+ this.writeJson(res, 202, { streamToken });
344
+ }
345
+ }
346
+
347
+ // ─── WebSocket ─────────────────────────────────────────────────────────────
348
+
349
+ private handleWsConnection(ws: WebSocket, req: IncomingMessage): void {
350
+ const url = new URL(req.url ?? "/", "http://localhost");
351
+ const cid = url.searchParams.get("cid");
352
+ if (!cid) { ws.close(1008, "Missing cid parameter"); return; }
353
+
354
+ const tok = url.searchParams.get("tok");
355
+
356
+ // In production mode, stream token is required.
357
+ // In devMode, the token is optional (hackathon / localhost convenience).
358
+ if (!this.opts.devMode) {
359
+ if (!tok) {
360
+ ws.close(1008, "Stream token required in production mode");
361
+ return;
362
+ }
363
+ // Consume first (atomic remove), then validate — prevents TOCTOU race
364
+ const entry = this.streamTokens.get(tok);
365
+ this.streamTokens.delete(tok);
366
+ if (!entry || entry.cid !== cid || entry.expiresMs < Date.now()) {
367
+ ws.close(1008, "Invalid or expired stream token");
368
+ return;
369
+ }
370
+ } else if (tok) {
371
+ // devMode but token provided — consume first, then validate
372
+ const entry = this.streamTokens.get(tok);
373
+ this.streamTokens.delete(tok);
374
+ if (!entry || entry.cid !== cid || entry.expiresMs < Date.now()) {
375
+ ws.close(1008, "Invalid or expired stream token");
376
+ return;
377
+ }
378
+ }
379
+
380
+ this.wsClients.set(cid, ws);
381
+ ws.on("close", () => this.wsClients.delete(cid));
382
+ }
383
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": true,
5
+ "outDir": "./dist",
6
+ "rootDir": "./src"
7
+ },
8
+ "include": ["src/**/*"],
9
+ "exclude": ["dist", "node_modules", "**/*.test.ts"]
10
+ }