@stacknet/stacks 0.1.2 → 0.2.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.
@@ -11,6 +11,22 @@ export interface SSEEvent {
11
11
  retry?: number;
12
12
  }
13
13
 
14
+ /** Hard cap on the in-memory buffer used by `parseSSEStream`. A malicious
15
+ * or malfunctioning upstream that never emits a newline would otherwise
16
+ * grow the buffer until the process OOMs. 1 MiB is far beyond any real
17
+ * SSE event. */
18
+ const MAX_SSE_BUFFER_BYTES = 1024 * 1024;
19
+
20
+ /** SSE framing uses newlines as field separators; any `\r` or `\n` in
21
+ * `event`, `id`, or a string `data` value would let an untrusted caller
22
+ * inject additional SSE frames that the receiver would process as if
23
+ * they came from the server. Strip them. */
24
+ function stripCRLF(value: string): string {
25
+ // Replace CR/LF/LS/PS with a single space so the field remains
26
+ // structurally valid without carrying a frame separator.
27
+ return value.replace(/[\r\n\u2028\u2029]/g, ' ');
28
+ }
29
+
14
30
  /**
15
31
  * Parse SSE stream from a Response
16
32
  */
@@ -32,6 +48,11 @@ export async function* parseSSEStream(
32
48
  if (done) break;
33
49
 
34
50
  buffer += decoder.decode(value, { stream: true });
51
+ if (buffer.length > MAX_SSE_BUFFER_BYTES) {
52
+ throw new Error(
53
+ `SSE buffer exceeded ${MAX_SSE_BUFFER_BYTES} bytes without an event delimiter`,
54
+ );
55
+ }
35
56
  const lines = buffer.split('\n');
36
57
  buffer = lines.pop() || '';
37
58
 
@@ -86,6 +107,43 @@ export async function* parseSSEStream(
86
107
  }
87
108
  }
88
109
 
110
+ /** Encode an SSE message safely — every field with CRLF stripped, data
111
+ * multi-line payloads split into repeated `data:` lines per the spec.
112
+ * Keeping this in one place lets both `createSSEResponse` and
113
+ * `SSEWriter.write` share exactly the same sanitization rules. */
114
+ function encodeEvent(event: SSEEvent): string {
115
+ let message = '';
116
+
117
+ if (event.event) {
118
+ message += `event: ${stripCRLF(event.event)}\n`;
119
+ }
120
+
121
+ if (event.id) {
122
+ message += `id: ${stripCRLF(event.id)}\n`;
123
+ }
124
+
125
+ if (event.retry !== undefined && Number.isFinite(event.retry)) {
126
+ // retry is numeric per the SSE spec — coerce defensively.
127
+ message += `retry: ${Math.floor(event.retry)}\n`;
128
+ }
129
+
130
+ const dataString =
131
+ typeof event.data === 'string'
132
+ ? event.data
133
+ : JSON.stringify(event.data);
134
+
135
+ // Per the SSE spec, data may span multiple lines — each line is a
136
+ // separate `data:` field. This also means an embedded newline in a
137
+ // string payload can't be used to smuggle a new frame: the blank
138
+ // line at the end of the event is produced by us, not the caller.
139
+ for (const dataLine of dataString.split(/\r?\n/)) {
140
+ message += `data: ${dataLine}\n`;
141
+ }
142
+
143
+ message += '\n';
144
+ return message;
145
+ }
146
+
89
147
  /**
90
148
  * Create an SSE Response from an async iterable
91
149
  */
@@ -99,28 +157,7 @@ export function createSSEResponse(
99
157
  async start(controller) {
100
158
  try {
101
159
  for await (const event of stream) {
102
- let message = '';
103
-
104
- if (event.event) {
105
- message += `event: ${event.event}\n`;
106
- }
107
-
108
- if (event.id) {
109
- message += `id: ${event.id}\n`;
110
- }
111
-
112
- if (event.retry) {
113
- message += `retry: ${event.retry}\n`;
114
- }
115
-
116
- const data =
117
- typeof event.data === 'string'
118
- ? event.data
119
- : JSON.stringify(event.data);
120
-
121
- message += `data: ${data}\n\n`;
122
-
123
- controller.enqueue(encoder.encode(message));
160
+ controller.enqueue(encoder.encode(encodeEvent(event)));
124
161
  }
125
162
  } catch (error) {
126
163
  console.error('SSE stream error:', error);
@@ -178,23 +215,7 @@ export class SSEWriter {
178
215
  */
179
216
  write(event: SSEEvent): void {
180
217
  if (!this.controller) return;
181
-
182
- let message = '';
183
-
184
- if (event.event) {
185
- message += `event: ${event.event}\n`;
186
- }
187
-
188
- if (event.id) {
189
- message += `id: ${event.id}\n`;
190
- }
191
-
192
- const data =
193
- typeof event.data === 'string' ? event.data : JSON.stringify(event.data);
194
-
195
- message += `data: ${data}\n\n`;
196
-
197
- this.controller.enqueue(this.encoder.encode(message));
218
+ this.controller.enqueue(this.encoder.encode(encodeEvent(event)));
198
219
  }
199
220
 
200
221
  /**
@@ -209,7 +230,11 @@ export class SSEWriter {
209
230
  */
210
231
  writeComment(comment: string): void {
211
232
  if (!this.controller) return;
212
- this.controller.enqueue(this.encoder.encode(`: ${comment}\n\n`));
233
+ // Comments are a single `:` line followed by a blank line. Strip any
234
+ // CRLF so a caller can't close out the comment and smuggle a real
235
+ // event after it.
236
+ const safe = stripCRLF(comment);
237
+ this.controller.enqueue(this.encoder.encode(`: ${safe}\n\n`));
213
238
  }
214
239
 
215
240
  /**
@@ -98,6 +98,8 @@ export interface AgentFromPromptInput {
98
98
 
99
99
  export interface AgentsClientConfig {
100
100
  baseUrl?: string;
101
+ /** JWT auth token for authenticated requests */
102
+ authToken?: string;
101
103
  /**
102
104
  * Use the coprocessor API (/cpx/agent/agents) instead of legacy (/agents).
103
105
  * Defaults to true. Set to false for backwards compatibility.